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
co-authored by Claude Fable 5
parent 98d533f045
commit 47b3b9f93a
66 changed files with 2421 additions and 459 deletions
+52 -6
View File
@@ -41,6 +41,7 @@ import {
} from './lib/contextBuilders/promptDetection';
import { stripAstraFormattingForAgentMode, computeModeSignature } from './lib/contextBuilders/systemPromptShaping';
import { sanitizeAssistantContent, isRestartedAnswer, parseRationale } from './lib/contextBuilders/outputSanitization';
import { LiveReasoningFilter } from './lib/contextBuilders/liveReasoningFilter';
import { buildEngineMessageVariants } from './lib/contextBuilders/engineMessages';
import { buildMemoryContext as buildMemoryContextFn } from './lib/contextBuilders/memoryContext';
import { extractEvidenceFilesFromProjectKnowledge, extractPriorityPreviewFiles } from './lib/contextBuilders/projectEvidence';
@@ -102,6 +103,7 @@ import {
} from './features/secondBrainTrace';
import { MemoryManager } from './memory';
import { RetrievalOrchestrator } from './retrieval';
import { embedQuery } from './retrieval/embeddings';
import { isQaRegressionFeedback, findUnaddressedChecklistItems } from './retrieval/lessonHelpers';
import { buildKnowledgeMixPolicy, ResolvedKnowledgeMix } from './retrieval/knowledgeMix';
import {
@@ -358,6 +360,44 @@ export class AgentExecutor {
this.restoreLastSession();
}
/**
* [코어 수렴] 기업 모드(dispatcher)용 두뇌 컨텍스트 블록 — 경량 scopedBrainRetriever
* 대신 메인 오케스트레이터의 전체 검색 경로(임베딩 하이브리드·청크)를 태운다.
* 텔레그램 경량 경로는 의존성 최소화를 위해 의도적으로 그대로 둔다.
* 반환 포맷은 기존 buildContextBlock 과 동일 — specialist 프롬프트 형식 불변.
*/
public async retrieveBrainBlockForCompany(query: string, scopeFolders: string[], limit: number): Promise<string> {
const config = getConfig();
const brain = getActiveBrainProfile();
if (!brain?.localBrainPath) return '';
let queryEmbedding: number[] | undefined;
if (config.embeddingModel) {
try {
queryEmbedding = await Promise.race([
embedQuery(query, { baseUrl: config.ollamaUrl, model: config.embeddingModel }),
new Promise<undefined>((resolve) => setTimeout(() => resolve(undefined), 4000)),
]);
} catch { queryEmbedding = undefined; }
}
const chunks = this.retrievalOrchestrator.retrieveBrainChunksScoped(query, brain, {
limit,
scopeFolders,
queryEmbedding,
embeddingModel: config.embeddingModel || undefined,
embeddingBlendAlpha: config.embeddingBlendAlpha,
chunkLevelRetrieval: config.chunkLevelRetrieval === true,
chunkTargetChars: config.chunkTargetChars,
});
if (chunks.length === 0) return '';
const header = scopeFolders.length > 0
? '[제2뇌 컨텍스트 — 매핑된 지식 폴더에서 검색]'
: '[제2뇌 컨텍스트 — 전체 브레인 검색]';
const body = chunks
.map((c, i) => `(#${i + 1}) ${c.title}\n${c.content}`)
.join('\n\n---\n\n');
return `${header}\n\n${body}`;
}
private async restoreLastSession() {
return restoreLastSessionFn({
sessionManager: this.sessionManager,
@@ -946,10 +986,16 @@ export class AgentExecutor {
// policy enforcement) emits a final `streamReplace` so the bubble
// ends up matching the cleaned answer regardless of what slipped
// through live.
// [Clean Stream] g1nation.liveStreamTokens=false (기본) 이면 토큰을 내부에만
// 누적하고 sanitize 끝난 최종 답변만 한 번에 표시 → Harmony/think 마커가 잠깐
// 화면에 노출되는 누설을 원천 차단한다. true 로 두면 legacy 라이브 스트리밍.
// [Clean Stream] liveStreamTokens=true(기본) 면 토큰을 실시간 표시하되,
// LiveReasoningFilter 가 <think>/<|channel|>thought 류 추론 구간을 토큰 단위로
// 걸러 사용자가 볼 필요 없는 생각 텍스트는 화면에 흐르지 않는다. false 면
// 내부 누적 후 sanitize 된 최종 답변만 한 번에 표시.
const postLiveDeltas = loopDepth === 0 && getConfig().liveStreamTokens === true;
const liveFilter = postLiveDeltas ? new LiveReasoningFilter() : null;
const postLiveToken = (token: string) => {
const visible = liveFilter ? liveFilter.push(token) : token;
if (visible) this.webview?.postMessage({ type: 'streamChunk', value: visible });
};
let lmStudioStats: ChatStreamStats | undefined;
if (useLmStudioSdk) {
@@ -970,7 +1016,7 @@ export class AgentExecutor {
if (this.isStaleRun(runId)) return;
if (token) {
aiResponseText += token;
if (postLiveDeltas) this.webview.postMessage({ type: 'streamChunk', value: token });
if (postLiveDeltas) postLiveToken(token);
}
if (stopReason) finishStopReason = stopReason;
if (stats) lmStudioStats = stats;
@@ -1045,7 +1091,7 @@ export class AgentExecutor {
const token = engine === 'lmstudio' ? json.choices?.[0]?.delta?.content || '' : json.message?.content || json.response || '';
if (token) {
aiResponseText += token;
if (postLiveDeltas) this.webview.postMessage({ type: 'streamChunk', value: token });
if (postLiveDeltas) postLiveToken(token);
}
const fr = engine === 'lmstudio'
? json.choices?.[0]?.finish_reason
@@ -1077,7 +1123,7 @@ export class AgentExecutor {
const token = engine === 'lmstudio' ? json.choices?.[0]?.delta?.content || '' : json.message?.content || json.response || '';
if (token) {
aiResponseText += token;
if (postLiveDeltas) this.webview.postMessage({ type: 'streamChunk', value: token });
if (postLiveDeltas) postLiveToken(token);
}
const fr = engine === 'lmstudio'
? json.choices?.[0]?.finish_reason
+28 -52
View File
@@ -1,6 +1,10 @@
import { execFile } from 'child_process';
import { detectPython, execFileCapture, _resetPythonCache } from '../../lib/execUtil';
import type { HandlerContext } from './types';
// [코어 수렴] 실행 래퍼·Python 탐지는 lib/execUtil 단일 구현을 사용한다.
// 기존 소비자(runCode·테스트) 호환을 위해 재수출.
export { detectPython, _resetPythonCache };
/**
* <calculate> — 수학·논리·수치 계산을 Python 에 위임하는 액션 (ACTION 16).
*
@@ -24,35 +28,6 @@ const MAX_CALC_PER_TURN = 4; // 한 턴에 과도한 실행 방지
const MAX_CODE_CHARS = 4_000;
const MAX_OUTPUT_CHARS = 4_000; // 모델 컨텍스트 보호
/** 감지된 Python 실행 파일 캐시 (프로세스 생존 동안 유지, null = 미탐지/없음). */
let _pythonCmd: string | null | undefined;
function execFileP(cmd: string, args: string[], timeout: number): Promise<{ stdout: string; stderr: string }> {
return new Promise((resolve, reject) => {
execFile(cmd, args, { timeout, maxBuffer: 512 * 1024 }, (err, stdout, stderr) => {
if (err) reject(Object.assign(err, { stdout: String(stdout || ''), stderr: String(stderr || '') }));
else resolve({ stdout: String(stdout || ''), stderr: String(stderr || '') });
});
});
}
/** python3 → python → py 순으로 실행 가능한 인터프리터 탐지 (OS 자동 대응, run_code 도 공유). */
export async function detectPython(): Promise<string | null> {
if (_pythonCmd !== undefined) return _pythonCmd;
for (const cmd of ['python3', 'python', 'py']) {
try {
await execFileP(cmd, ['--version'], 3_000);
_pythonCmd = cmd;
return cmd;
} catch { /* 다음 후보 */ }
}
_pythonCmd = null;
return null;
}
/** 테스트용 — 캐시 초기화. */
export function _resetPythonCache(): void { _pythonCmd = undefined; }
export async function applyCalculateActions(ctx: HandlerContext): Promise<void> {
const { aiMessage, report } = ctx;
const calcRegex = /<calculate>([\s\S]*?)<\/calculate>/gi;
@@ -83,32 +58,33 @@ export async function applyCalculateActions(ctx: HandlerContext): Promise<void>
}
const label = code.replace(/\s+/g, ' ').slice(0, 60);
try {
const { stdout } = await execFileP(python, ['-I', '-c', code], CALC_TIMEOUT_MS);
const out = stdout.trim();
if (!out) {
// print 누락 — 모델이 스스로 고치도록 힌트를 재주입한다.
report.push(`⚠️ Calculated (no output): ${label}`);
ctx.chatHistory.push({
role: 'system', internal: true,
content: '[Result of calculate — EMPTY OUTPUT]\n코드는 실행됐지만 출력이 없습니다. 결과는 반드시 print(...) 로 출력해야 합니다. print 를 추가해 <calculate> 를 다시 emit 하십시오.',
});
} else {
const capped = out.length > MAX_OUTPUT_CHARS ? out.slice(0, MAX_OUTPUT_CHARS) + '\n… (truncated)' : out;
report.push(`🧮 Calculated: ${label}`);
ctx.chatHistory.push({
role: 'system', internal: true,
content: `[Result of calculate]\n\`\`\`\n${capped}\n\`\`\`\n이 실행 결과를 신뢰하고 그대로 사용해 답하십시오 (임의로 다시 암산하지 말 것).`,
});
}
} catch (err: any) {
const r = await execFileCapture(python, ['-I', '-c', code], { timeoutMs: CALC_TIMEOUT_MS, maxBuffer: 512 * 1024 });
if (r.timedOut || r.code !== 0) {
// 에러도 재주입 — 모델이 코드를 고쳐 재시도하는 자가수정 루프.
const tail = String(err?.stderr || err?.message || err).trim().split('\n').slice(-8).join('\n').slice(0, 1200);
const timedOut = err?.killed || /ETIMEDOUT|timed? ?out/i.test(String(err?.message || ''));
const tail = (r.stderr || r.stdout || `exit ${r.code}`).trim().split('\n').slice(-8).join('\n').slice(0, 1200);
report.push(`❌ Calculate failed: ${label}`);
ctx.chatHistory.push({
role: 'system', internal: true,
content: `[Result of calculate — ERROR]\n${timedOut ? `실행이 ${CALC_TIMEOUT_MS / 1000}초 안에 끝나지 않아 중단되었습니다. 더 효율적인 방법으로 다시 시도하십시오.` : `\`\`\`\n${tail}\n\`\`\`\n에러를 참고해 코드를 고친 뒤 <calculate> 를 다시 emit 하십시오. (표준 라이브러리만 사용 가능)`}`,
content: `[Result of calculate — ERROR]\n${r.timedOut ? `실행이 ${CALC_TIMEOUT_MS / 1000}초 안에 끝나지 않아 중단되었습니다. 더 효율적인 방법으로 다시 시도하십시오.` : `\`\`\`\n${tail}\n\`\`\`\n에러를 참고해 코드를 고친 뒤 <calculate> 를 다시 emit 하십시오. (표준 라이브러리만 사용 가능)`}`,
});
continue;
}
const out = r.stdout.trim();
if (!out) {
// print 누락 — 모델이 스스로 고치도록 힌트를 재주입한다.
report.push(`⚠️ Calculated (no output): ${label}`);
ctx.chatHistory.push({
role: 'system', internal: true,
content: '[Result of calculate — EMPTY OUTPUT]\n코드는 실행됐지만 출력이 없습니다. 결과는 반드시 print(...) 로 출력해야 합니다. print 를 추가해 <calculate> 를 다시 emit 하십시오.',
});
} else {
const capped = out.length > MAX_OUTPUT_CHARS ? out.slice(0, MAX_OUTPUT_CHARS) + '\n… (truncated)' : out;
report.push(`🧮 Calculated: ${label}`);
ctx.chatHistory.push({
role: 'system', internal: true,
content: `[Result of calculate]\n\`\`\`\n${capped}\n\`\`\`\n이 실행 결과를 신뢰하고 그대로 사용해 답하십시오 (임의로 다시 암산하지 말 것).`,
});
}
}
+2 -16
View File
@@ -1,8 +1,7 @@
import { execFile } from 'child_process';
import * as fs from 'fs';
import * as path from 'path';
import { validatePath } from '../../security';
import { detectPython } from './calculate';
import { detectPython, execFileCapture } from '../../lib/execUtil';
import type { HandlerContext } from './types';
/**
@@ -22,19 +21,6 @@ const RUN_TIMEOUT_MS = 15_000;
const MAX_RUNS_PER_TURN = 2;
const MAX_OUTPUT_CHARS = 4_000;
function execFileP(cmd: string, args: string[], cwd: string): Promise<{ code: number; stdout: string; stderr: string; timedOut: boolean }> {
return new Promise((resolve) => {
execFile(cmd, args, { cwd, timeout: RUN_TIMEOUT_MS, maxBuffer: 1024 * 1024 }, (err: any, stdout, stderr) => {
resolve({
code: err ? (typeof err.code === 'number' ? err.code : 1) : 0,
stdout: String(stdout || ''),
stderr: String(stderr || ''),
timedOut: !!err?.killed,
});
});
});
}
const cap = (s: string) => (s.length > MAX_OUTPUT_CHARS ? s.slice(0, MAX_OUTPUT_CHARS) + '\n… (truncated)' : s);
export async function applyRunCodeActions(ctx: HandlerContext): Promise<void> {
@@ -87,7 +73,7 @@ export async function applyRunCodeActions(ctx: HandlerContext): Promise<void> {
}
const rel = path.relative(rootPath, absPath) || relPath;
const res = await execFileP(cmd, args, rootPath);
const res = await execFileCapture(cmd, args, { timeoutMs: RUN_TIMEOUT_MS, cwd: rootPath, maxBuffer: 1024 * 1024 });
const outBlock = [
res.stdout.trim() ? `--- stdout ---\n${cap(res.stdout.trim())}` : '(stdout 없음)',
res.stderr.trim() ? `--- stderr ---\n${cap(res.stderr.trim())}` : '',
@@ -1,3 +1,4 @@
import { buildToneGuardBlock } from '../../lib/contextBuilders/toneGuard';
import {
isProjectKnowledgeCreationRequest,
buildAstraStanceContext,
@@ -76,7 +77,7 @@ export function buildAstraModeSystemPrompt(input: BuildAstraModeSystemPromptInpu
// [CONTEXT] … [/CONTEXT] 사이만 컨텍스트 초과 시 trim 대상 — negative constraints 는 보호.
const casualCtx = isCasualConversation
? '\n\n[CASUAL CONVERSATION MODE]\nThe user sent a greeting, acknowledgement, or light conversational message. Reply naturally and briefly to the message itself. Do not use Second Brain, memory, project records, reports, references, or analysis unless the user explicitly asks for them.'
? `\n\n[CASUAL CONVERSATION MODE]\nThe user sent a greeting, acknowledgement, or light conversational message. Reply naturally and briefly to the message itself. Do not use Second Brain, memory, project records, reports, references, or analysis unless the user explicitly asks for them.\n\n${buildToneGuardBlock()}`
: '';
// Knowledge Mix policy: tells the model how strongly to lean on Second Brain
// evidence vs. its own general knowledge for this turn. Suppressed for casual
+5 -1
View File
@@ -1,4 +1,5 @@
import * as vscode from 'vscode';
import { LiveReasoningFilter } from '../../lib/contextBuilders/liveReasoningFilter';
import { logError, summarizeText } from '../../utils';
import { lmStudioSamplingFromConfig, lmStudioRespondExtrasFromConfig } from '../../lib/contextBuilders/lmStudioSampling';
import type { AgentExecutorOptions, ChatMessage } from '../../agent';
@@ -35,9 +36,12 @@ export async function streamChatOnce(deps: StreamChatOnceDeps, params: {
}): Promise<{ text: string; stopReason?: string; aborted: boolean }> {
let accumulated = '';
let finishStopReason: string | undefined;
// 라이브 표시 시 추론 구간(<think>/Harmony thought)은 토큰 단위로 차단 — agent.ts 본 스트림과 동일.
const liveFilter = new LiveReasoningFilter();
const post = (token: string) => {
if (params.postLiveDeltas && token) {
deps.getWebview()?.postMessage({ type: 'streamChunk', value: token });
const visible = liveFilter.push(token);
if (visible) deps.getWebview()?.postMessage({ type: 'streamChunk', value: visible });
}
};
+41 -4
View File
@@ -1,10 +1,11 @@
import * as vscode from 'vscode';
import { findBrainFiles, getActiveBrainProfile, logError } from '../../utils';
import { findBrainFiles, getActiveBrainProfile, logError, logInfo } from '../../utils';
import { getConfig } from '../../config';
import { AgentWorkflowManager } from '../../agents/AgentWorkflowManager';
import { ErrorTranslator } from '../../core/errorHandler';
import { StatusBarManager, AgentStatus } from '../../core/statusBar';
import { stripMarkdownFormatting } from '../../core/responseRecovery';
import { buildIntentBrief, formatIntentBrief, runReportQaLoop, IntentBrief } from '../../agents/reportQa';
import type { AgentExecutorOptions, ChatMessage } from '../../agent';
export interface WorkflowDeps {
@@ -50,6 +51,22 @@ export async function executeMultiAgentWorkflow(
logError('Failed to load brain context for agents', ctxErr);
}
// ── [의도 브리핑] 왜 묻는지·진짜 궁금증·답의 용도를 먼저 분석해 모든 단계
// (outline/section/polish)의 컨텍스트에 주입 — "문자적 답변" 방지의 1차 장치.
// 실패해도 파이프라인은 계속 (실패는 reportQa 모듈이 로그로 드러낸다).
let intentBrief: IntentBrief | null = null;
let intentCtx = '';
if (getConfig().reportQaEnabled) {
webview.postMessage({ type: 'workflowStage', value: { step: '⓪ 의도 분석', message: '질문의 목적을 파악하는 중...', done: false } });
const recentContext = deps.chatHistory
.filter(m => !(m as any).internal && (m.role === 'user' || m.role === 'assistant'))
.slice(-4)
.map(m => `${m.role === 'user' ? '사용자' : '아스트라'}: ${String(m.content || '').replace(/\s+/g, ' ').slice(0, 120)}`)
.join('\n');
intentBrief = await buildIntentBrief({ prompt, recentContext, model: modelName, signal });
if (intentBrief) intentCtx = `${formatIntentBrief(intentBrief)}\n\n`;
}
const selectedAgentContext = options.agentSkillContext
? `\nSelected Agent Reference:\n${options.agentSkillContext}`
: '';
@@ -64,7 +81,7 @@ export async function executeMultiAgentWorkflow(
const rawFinalReport = await AgentWorkflowManager.runStrictWorkflow(
prompt,
modelName,
`${brainContext}${selectedAgentContext}${designerContext}`,
`${intentCtx}${brainContext}${selectedAgentContext}${designerContext}`,
signal,
(step, msg) => {
deps.getWebview()?.postMessage({
@@ -77,12 +94,32 @@ export async function executeMultiAgentWorkflow(
const wv2 = deps.getWebview();
if (signal.aborted || !wv2) return;
// ── [Report QA Loop] 조사·보고서형 요청은 블로그_v3 방식 품질 루프를 통과 —
// QA 규칙 레지스트리+실측치 주입으로 채점, 임계 미달이면 피드백 재작성,
// 점수가 떨어지면 재작성본 폐기(회귀 게이트). 일반 복합 질문(코드 분석 등)은
// 보고서 기준이 안 맞으므로 조사성 키워드가 있을 때만 발동.
let qaCheckedReport = rawFinalReport;
const isResearchLike = /(조사|리서치|보고서|레포트|알아봐|심층|종합\s*분석|research|report)/i.test(prompt);
if (getConfig().reportQaEnabled && isResearchLike && !signal.aborted) {
wv2.postMessage({ type: 'workflowStage', value: { step: '④ 품질 검증', message: '보고서를 채점하는 중...', done: false } });
const qa = await runReportQaLoop({
draft: rawFinalReport,
prompt,
intentBrief,
model: modelName,
signal,
onProgress: (msg) => deps.getWebview()?.postMessage({ type: 'workflowStage', value: { step: '④ 품질 검증', message: msg, done: false } }),
});
qaCheckedReport = qa.finalDraft;
logInfo('[Workflow] Report QA 완료.', { score: qa.score, revisions: qa.revisions, trajectory: qa.trajectory.join('→') });
}
// [Plain Text Output] Synthesizer가 잘 따라줬어도 작은 모델은 `##` `**` 를 흘리는 경우가 있어
// 최종 후처리로 한 번 더 마커를 벗긴다. 채팅 history 에도 정제된 결과만 남겨 다음 턴 컨텍스트에서
// 마커가 재학습되는 일을 막는다.
const finalReport = getConfig().outputFormat === 'plain'
? stripMarkdownFormatting(rawFinalReport)
: rawFinalReport;
? stripMarkdownFormatting(qaCheckedReport)
: qaCheckedReport;
wv2.postMessage({ type: 'streamChunk', value: finalReport });
wv2.postMessage({ type: 'workflowStage', value: { step: '완료', message: '', done: true } });
+12 -13
View File
@@ -104,7 +104,6 @@ const confidenceEscalationHook: PostAnswerHook = {
runAsync: false,
run(ctx: PostAnswerHookContext): void {
const cfg = getConfig();
if (cfg.confidenceEngineEnabled === false) return;
if (!ctx.userPrompt.trim() || !ctx.assistantAnswer.trim()) return;
// 검색이 안 돈 turn (casual 등) 은 신호 null → 보수적 기본값 (근거 0건).
@@ -118,21 +117,21 @@ const confidenceEscalationHook: PostAnswerHook = {
);
const confidence = computeConfidence(retrievalSignals, answerSignals);
// 업무 산출물 turn 에만 footer 표시 — 잡담까지 점수 붙이면 노이즈.
// 단, 확신도 '매우 낮음' 은 업무 여부와 무관하게 표시 (T4).
const isTask = coverage.ran || coverage.taskId !== undefined;
if (!isTask && confidence.band !== 'very-low') return;
const decision = decideEscalation({
confidence, coverage, conflictCount: retrievalSignals.conflictCount,
});
const escalated = decision.escalate;
let footer = formatConfidenceFooter(confidence);
let escalated = false;
if (cfg.escalationEnabled !== false) {
const decision = decideEscalation({
confidence, coverage, conflictCount: retrievalSignals.conflictCount,
});
escalated = decision.escalate;
footer += formatEscalationFooter(decision);
// [v2.2.300] 확신도·검토요청 footer 는 기본 *비표시* — 잡담 인사에까지
// "확신도 40/100 · 사람 확인 필요" 가 붙는 노이즈라는 사용자 피드백.
// 점수 계산과 아래 Reflection 기록은 그대로 유지 (성장 추이·Failure Pattern 의
// 원천은 화면 표시가 아니라 기록이다). 다시 보고 싶으면 설정에서 켜기 (opt-in).
if (cfg.confidenceEngineEnabled === true && (isTask || confidence.band === 'very-low')) {
let footer = formatConfidenceFooter(confidence);
if (cfg.escalationEnabled === true) footer += formatEscalationFooter(decision);
if (footer) ctx.getWebview()?.postMessage({ type: 'streamChunk', value: footer });
}
if (footer) ctx.getWebview()?.postMessage({ type: 'streamChunk', value: footer });
// ── Reflection 기록 (Track 2-4 / 3-6) — 업무 turn 의 결정론적 회고를
// <brain>/.astra/growth/reflections.jsonl 에 적재. 성장 추이·Failure Pattern 의 원천.
+10 -37
View File
@@ -17,6 +17,8 @@
* 위치: 답변 streaming 완료 후, `usedScope` 메시지 전송 직전. 비동기 — 답변
* 표시를 *블록 하지 않음*. 결과는 webview 에 별도 메시지로 push.
*/
import { coreChat } from '../core/services';
export interface SelfCheckOptions {
ollamaUrl: string;
@@ -141,53 +143,24 @@ export async function postHocSelfCheck(
const sourcesCap = (sources || []).slice(0, options.maxSources);
const { system, user } = buildPrompt(userPrompt, answer, sourcesCap, options.excerptLength);
const isOllama = options.ollamaUrl.includes(':11434') || options.ollamaUrl.includes('ollama');
const endpoint = isOllama ? `${options.ollamaUrl}/api/chat` : `${options.ollamaUrl}/v1/chat/completions`;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), options.timeoutMs);
// [코어 수렴] 자체 fetch·엔진 휴리스틱 제거 — coreChat 으로 통일 (온도 0, 200토큰).
// (이 모듈은 원래 import 0개의 자립 모듈이었으나, 엔진 폴백·로깅 일원화가 더 큰 가치)
let raw = '';
try {
const body = isOllama
? {
model: options.model, stream: false,
messages: [
{ role: 'system', content: system },
{ role: 'user', content: user },
],
options: { temperature: 0.0, num_predict: 200 },
}
: {
model: options.model, stream: false, temperature: 0.0, max_tokens: 200,
messages: [
{ role: 'system', content: system },
{ role: 'user', content: user },
],
};
const res = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
signal: controller.signal,
const result = await coreChat({
system, user, model: options.model,
timeoutMs: options.timeoutMs,
temperature: 0.0,
maxTokens: 200,
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data: any = await res.json();
raw = String(
data?.message?.content ??
data?.choices?.[0]?.message?.content ??
data?.choices?.[0]?.text ??
data?.response ??
'',
);
raw = String(result.content || '');
} catch (e: any) {
clearTimeout(timer);
return {
...FAILURE_RESULT,
note: `LLM call failed: ${e?.name || e?.message || 'unknown'}`,
durationMs: Date.now() - start,
rawResponse: '',
};
} finally {
clearTimeout(timer);
}
const parsed = parseResult(raw);
+14 -71
View File
@@ -1,82 +1,37 @@
import * as vscode from 'vscode';
import { getConfig } from '../config';
import { coreChat } from '../core/services';
import { AgentExecuteOptions } from '../lib/engine';
export abstract class BaseAgent {
constructor(protected readonly modelName: string) {}
protected async callLLM(persona: string, prompt: string, signal?: AbortSignal): Promise<string> {
const { ollamaUrl } = getConfig();
if (!ollamaUrl) {
throw new Error('Ollama URL이 설정되지 않았습니다. 설정을 확인해주세요.');
}
if (typeof fetch === 'undefined') {
throw new Error('이 환경에서는 fetch 함수를 사용할 수 없습니다. Node.js 버전을 확인하거나 polyfill이 필요합니다.');
}
const messages = [
{ role: 'system', content: persona },
{ role: 'user', content: prompt }
];
// 엔진 자동 감지 (Ollama vs OpenAI/LM Studio)
const isOllama = ollamaUrl.includes(':11434') || ollamaUrl.includes('ollama');
const endpoint = isOllama ? `${ollamaUrl}/api/chat` : `${ollamaUrl}/v1/chat/completions`;
// 컨텍스트 초과 방지를 위해 출력 토큰 상한을 항상 명시한다 (서브에이전트 중간 산출물용).
// [코어 수렴] 자체 fetch·엔진 휴리스틱을 제거하고 core/services 의 coreChat 으로 통일.
// 이 레이어는 "시도당 45초 + 3회 재시도(지수 백오프) + Abort 즉시 중단" 정책만 담당하고,
// 엔진 선택·폴백·빈응답 처리·로깅은 코어가 책임진다.
const { contextLength, maxOutputTokens } = getConfig();
const numCtx = Math.max(2048, contextLength);
const outCap = Math.max(256, maxOutputTokens);
let lastError: any;
for (let attempt = 1; attempt <= 3; attempt++) {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 45000);
const combinedSignal = signal ? anySignal([signal, controller.signal]) : controller.signal;
try {
if (attempt > 1) await new Promise(resolve => setTimeout(resolve, 1000 * attempt));
const response = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(isOllama ? {
const result = await coreChat({
system: persona,
user: prompt,
model: this.modelName,
messages,
stream: false,
options: { temperature: 0.3, num_ctx: numCtx, num_predict: outCap }
} : {
model: this.modelName,
messages,
stream: false,
timeoutMs: 45000,
signal,
temperature: 0.3,
max_tokens: outCap
}),
signal: combinedSignal
});
clearTimeout(timeoutId);
if (!response.ok) {
throw new Error(`Agent API Error: ${response.statusText} (${response.status})`);
}
const data = await response.json() as any;
// 강력한 응답 추출 (Multi-path parsing)
let content = '';
if (data.message?.content) content = data.message.content;
else if (data.choices?.[0]?.message?.content) content = data.choices[0].message.content;
else if (data.choices?.[0]?.text) content = data.choices[0].text;
else if (data.response) content = data.response;
else if (typeof data === 'string') content = data;
return content || '';
maxTokens: outCap,
numCtx,
});
return result.content || '';
} catch (error: any) {
clearTimeout(timeoutId);
lastError = error;
if (error.name === 'AbortError') break;
if (error?.name === 'AbortError') break;
if (attempt === 3) break;
}
}
@@ -86,18 +41,6 @@ export abstract class BaseAgent {
abstract execute(input: string, context?: string, signal?: AbortSignal, options?: AgentExecuteOptions): Promise<string>;
}
// Helper to combine signals (since AbortSignal.any is not always available in older Node)
function anySignal(signals: AbortSignal[]): AbortSignal {
const controller = new AbortController();
for (const signal of signals) {
if (signal.aborted) {
controller.abort();
return signal;
}
signal.addEventListener('abort', () => controller.abort(), { once: true });
}
return controller.signal;
}
/**
* Section outline shape produced by ChunkedWriter in the 'outline' role.
+259
View File
@@ -0,0 +1,259 @@
import { coreChat } from '../core/services';
import { getConfig } from '../config';
import { logInfo, logWarn } from '../utils';
/**
* 조사·보고서 답변 품질 루프 — 블로그_v3 파이프라인의 검증 설계를 개념 이식.
*
* 이식한 개념 (원본: 블로그_v3 index.tsx — raw 복사가 아니라 보고서 도메인으로 적응):
* 1. QA 규칙 레지스트리 단일 권위 — 채점 항목은 전부 REPORT_QA_RULES 배열에서
* 생성된다. 규칙 추가/수정은 여기 한 곳만 고치면 된다.
* 2. 결정적 실측치 주입 — 분량·섹션 수·출처 인용 수를 코드로 측정해 QA 프롬프트에
* 주입한다. 모델의 눈대중 추정(들쭉날쭉)이 아니라 실측치로 판단을 안정화.
* 3. 채점→피드백 재주입→재작성 루프 + 회귀 게이트 — 점수 미달이면 피드백을 넣어
* 재작성하고 다시 채점하되, 점수가 *떨어지면* 그 재작성본은 버리고 최고점
* 초안(best draft)을 유지한다 (85→79 회귀 사고의 재발 방지 장치).
*
* 소형 로컬 모델 전제: 라운드 수 기본 2회로 제한 (호출당 수십 초 — latency 보호),
* 채점 실패(JSON 파싱 불가·타임아웃)는 루프 중단 + 현재 최고 초안 반환 (조용한
* 폴백 금지 — 로그에 남긴다).
*/
// ── QA 규칙 레지스트리 (단일 권위) ─────────────────────────────────────────
// num: 항목 번호 — 피드백이 "N번"으로 참조하므로 기존 번호는 바꾸지 말고 새 규칙은 뒤에 추가.
// critical: true 면 '중대한 결함'(임계 미달 사유) 목록에 자동 포함.
export type ReportQaRule = { num: number; text: string; critical?: boolean };
export const REPORT_QA_RULES: ReportQaRule[] = [
{ num: 1, text: '결론 선행 — 첫 문단이 질문에 대한 직접적인 답(핵심 결론 요약)으로 시작하는가. 배경 설명부터 길게 시작하면 감점', critical: true },
{ num: 2, text: '의도 부합 — 함께 제공되는 [질문자 의도 브리핑]의 목적·진짜 궁금증에 본문이 실제로 답하는가. 표면 질문에만 답하고 목적을 놓쳤으면 크게 감점', critical: true },
{ num: 3, text: '근거 명시 — 주장·사실마다 근거(제공된 자료의 문서명·출처, 또는 "일반 지식 기반" 표시)가 붙어 있는가. 출처 없는 단정이 반복되면 감점', critical: true },
{ num: 4, text: '임의 수치 금지 — 제공된 자료에 없는 깔끔한 구체 수치(통계·비율·금액·날짜)를 지어내지 않았는가. 하나라도 보이면 크게 감점하고 feedback 에 해당 수치를 인용하라', critical: true },
{ num: 5, text: '사실·추정 구분 — 확실한 사실과 추정·의견이 구분 표기되는가 ("~로 확인됨" vs "~로 추정됨"). 뒤섞여 있으면 감점' },
{ num: 6, text: '구조 — 소제목으로 논리가 전개되고 (배경→핵심 발견→분석→시사점 류), 섹션 간 내용 중복이 없는가' },
{ num: 7, text: '완결성 — 질문이 여러 하위 질문을 포함하면 전부 다뤘는가. 빠뜨린 하위 질문이 있으면 감점하고 feedback 에 명시하라', critical: true },
{ num: 8, text: '한계 명시 — 이번 조사에서 확인하지 못한 것·추가 확인이 필요한 것이 마지막에 정직하게 표기되는가. 전지적 톤으로 끝나면 감점' },
{ num: 9, text: '다음 행동 — 질문자가 이 답을 받고 취할 수 있는 다음 단계·시사점이 구체적으로 제시되는가' },
{ num: 10, text: '군더더기 금지 — "물론입니다", "~에 대해 알아보겠습니다" 류 서두·맺음 인사, 동어반복 문단이 없는가' },
{ num: 11, text: '나열 남용 금지 — 본문 대부분이 얕은 불릿 나열이면 감점. 핵심 논점은 문장·문단으로 설명되고 나열은 보조로만 쓰여야 한다' },
{ num: 12, text: '분량 적정 — 함께 제공되는 실측치(본문 글자 수)를 그대로 사용하여(직접 추정 금지), 질문의 무게 대비 지나치게 짧아 부실하거나 반복으로 부풀려져 있지 않은가' },
];
export const buildReportQaItems = (): string =>
REPORT_QA_RULES.map(r => `(${r.num}) ${r.text}`).join('\n');
export const reportQaCriticalNums = (): string =>
REPORT_QA_RULES.filter(r => r.critical).map(r => r.num).join('·');
// ── 결정적 실측치 (모델 눈대중 대신 코드 측정) ──────────────────────────────
export interface ReportMetrics {
/** 마크다운 문법 제외 본문 글자 수 (공백 포함). */
contentChars: number;
/** 소제목(##/### 또는 굵은 단독행) 개수. */
sectionCount: number;
/** 출처 인용 흔적 수 — [문서명]·「」·(출처: …)·"일반 지식" 표기. */
citationCount: number;
/** 불릿/번호 행이 전체 비어있지 않은 행에서 차지하는 비율 (0~100). */
listLineRatio: number;
}
export function measureReport(text: string): ReportMetrics {
const t = text || '';
const contentChars = t
.replace(/```[\s\S]*?```/g, ' ')
.replace(/[#*_`>|-]/g, '')
.replace(/\s+/g, ' ')
.trim().length;
const lines = t.split('\n').map(l => l.trim()).filter(Boolean);
const sectionCount = lines.filter(l => /^#{2,4}\s+\S/.test(l) || /^\*\*[^*]{2,40}\*\*$/.test(l)).length;
const citationCount =
(t.match(/\(출처[::][^)]*\)/g) || []).length +
(t.match(/「[^」]{2,60}」/g) || []).length +
(t.match(/\[[^\]\n]{2,60}\](?!\()/g) || []).length +
(t.match(/일반 지식/g) || []).length;
const listLines = lines.filter(l => /^([-*•]|\d+[.)])\s/.test(l)).length;
const listLineRatio = lines.length ? Math.round((listLines / lines.length) * 100) : 0;
return { contentChars, sectionCount, citationCount, listLineRatio };
}
// ── 관대한 JSON 파싱 (코드펜스·앞뒤 잡담 허용) — 블로그_v3 parseLooseJson 개념 ──
export function parseLooseJson(raw: string): any {
if (!raw) return null;
const t = raw.replace(/```json/gi, '').replace(/```/g, '').trim();
try { return JSON.parse(t); } catch { /* fall through */ }
const start = t.indexOf('{');
const end = t.lastIndexOf('}');
if (start !== -1 && end > start) {
try { return JSON.parse(t.slice(start, end + 1)); } catch { /* fall through */ }
}
return null;
}
// ── 질문자 의도 브리핑 (파이프라인 선두 1회 호출) ───────────────────────────
export interface IntentBrief {
/** 질문자가 왜 지금 이걸 묻는지 — 추정 목적. */
purpose: string;
/** 표면 질문 뒤의 진짜 궁금증들. */
realQuestions: string[];
/** 답을 받아 무엇을 하려는지 — 기대 산출물 형태. */
expectedUse: string;
}
export function formatIntentBrief(brief: IntentBrief): string {
return [
'[질문자 의도 브리핑]',
`- 추정 목적: ${brief.purpose}`,
`- 진짜 궁금증: ${brief.realQuestions.join(' / ')}`,
`- 답의 용도: ${brief.expectedUse}`,
'위 브리핑에 실제로 답하는 것이 이번 답변의 성공 기준이다. 표면 질문만 문자적으로 답하지 마라.',
].join('\n');
}
/**
* 사용자 질문 + 최근 대화 요지로 의도 브리핑을 1회 생성. 실패 시 null (호출자가
* 브리핑 없이 진행 — 실패는 로그로 드러낸다, 조용한 폴백 금지).
*/
export async function buildIntentBrief(params: {
prompt: string;
recentContext?: string;
model?: string;
signal?: AbortSignal;
}): Promise<IntentBrief | null> {
try {
const res = await coreChat({
system: '너는 질문 분석가다. 사용자의 요청을 읽고 왜 묻는지(목적), 표면 질문 뒤에 정말 궁금한 것, 답을 받아 무엇을 할지 분석해 JSON 으로만 응답한다. 형식: {"purpose":"...","realQuestions":["...","..."],"expectedUse":"..."} — 각 항목은 한국어 한 문장, realQuestions 는 1~3개.',
user: `${params.recentContext ? `[최근 대화 요지]\n${params.recentContext}\n\n` : ''}[사용자 요청]\n${params.prompt}`,
model: params.model,
temperature: 0.2,
maxTokens: 300,
timeoutMs: 30_000,
signal: params.signal,
});
const parsed = parseLooseJson(res.content);
if (parsed && typeof parsed.purpose === 'string' && Array.isArray(parsed.realQuestions)) {
return {
purpose: parsed.purpose,
realQuestions: parsed.realQuestions.map(String).filter(Boolean).slice(0, 3),
expectedUse: String(parsed.expectedUse || '명시되지 않음'),
};
}
logWarn('[ReportQA] 의도 브리핑 JSON 파싱 실패 — 브리핑 없이 진행.', { raw: res.content.slice(0, 120) });
return null;
} catch (e: any) {
logWarn('[ReportQA] 의도 브리핑 호출 실패 — 브리핑 없이 진행.', { error: e?.message || String(e) });
return null;
}
}
// ── QA 채점 + 재작성 루프 + 회귀 게이트 ────────────────────────────────────
export interface QaLoopResult {
finalDraft: string;
/** 최종 채택 초안의 점수. 채점이 한 번도 성공 못 하면 null. */
score: number | null;
/** 실행된 재작성 횟수. */
revisions: number;
/** 라운드별 점수 궤적 (로그·텔레메트리용). */
trajectory: number[];
}
async function scoreOnce(params: {
draft: string;
prompt: string;
intentText: string;
model?: string;
signal?: AbortSignal;
}): Promise<{ score: number; feedback: string } | null> {
const m = measureReport(params.draft);
const metricsGuide =
`[실측치 — 그대로 사용, 직접 추정 금지] 본문 글자 수 ${m.contentChars}자, 소제목 ${m.sectionCount}개, ` +
`출처 인용 흔적 ${m.citationCount}건, 나열(불릿) 행 비율 ${m.listLineRatio}%.`;
try {
const res = await coreChat({
system: `너는 조사 보고서 품질 검수관이다. 아래 기준으로 엄격히 채점해 JSON 으로만 응답한다: {"score": 0~100, "feedback": "부족한 항목 번호와 해당 구절 인용"}.\n[채점 기준]\n${buildReportQaItems()}\n[점수 규칙] 모든 항목 충족 시 85점 이상. 사소한 미흡 1개면 80~84점. 중대 결함(${reportQaCriticalNums()}번 위반) 또는 미흡 2개 이상이면 80점 미만. 점수와 무관하게 feedback 에는 무엇이 부족한지 구체적 구절을 인용해 반드시 명시하라.`,
user: `${params.intentText}\n\n[원 질문]\n${params.prompt}\n\n${metricsGuide}\n\n[검수 대상 보고서]\n${params.draft}`,
model: params.model,
temperature: 0.0,
maxTokens: 500,
timeoutMs: 45_000,
signal: params.signal,
});
const parsed = parseLooseJson(res.content);
if (parsed && typeof parsed.score === 'number') {
return { score: Math.max(0, Math.min(100, parsed.score)), feedback: String(parsed.feedback || '') };
}
logWarn('[ReportQA] 채점 JSON 파싱 실패.', { raw: res.content.slice(0, 120) });
return null;
} catch (e: any) {
logWarn('[ReportQA] 채점 호출 실패.', { error: e?.message || String(e) });
return null;
}
}
/**
* 초안을 채점하고, 임계 미달이면 피드백을 재주입해 재작성 → 재채점.
* 재작성본 점수가 이전 최고점보다 낮으면 폐기(회귀 게이트).
*/
export async function runReportQaLoop(params: {
draft: string;
prompt: string;
intentBrief: IntentBrief | null;
model?: string;
signal?: AbortSignal;
onProgress?: (message: string) => void;
}): Promise<QaLoopResult> {
const cfg = getConfig();
const threshold = cfg.reportQaThreshold;
const maxRevisions = cfg.reportQaMaxRevisions;
const intentText = params.intentBrief ? formatIntentBrief(params.intentBrief) : '[질문자 의도 브리핑] (생성 실패 — 원 질문 기준으로 판단하라)';
let bestDraft = params.draft;
let bestScore: number | null = null;
const trajectory: number[] = [];
let revisions = 0;
let current = await scoreOnce({ draft: bestDraft, prompt: params.prompt, intentText, model: params.model, signal: params.signal });
if (!current) return { finalDraft: bestDraft, score: null, revisions: 0, trajectory };
bestScore = current.score;
trajectory.push(current.score);
logInfo('[ReportQA] 초안 채점.', { score: current.score, threshold });
while (bestScore < threshold && revisions < maxRevisions) {
if (params.signal?.aborted) break;
revisions++;
params.onProgress?.(`품질 ${bestScore}점 — 보완 재작성 ${revisions}/${maxRevisions}`);
let revised: string;
try {
const res = await coreChat({
system: '너는 조사 보고서 작성자다. 아래 검수 피드백을 반영해 보고서 *전문*을 다시 작성하라. 지적받지 않은 부분의 사실·구조는 유지하고, 지적된 부분만 고친다. 보고서 본문만 출력하고 다른 말은 하지 마라.',
user: `${intentText}\n\n[원 질문]\n${params.prompt}\n\n[검수 피드백 (점수 ${bestScore}/${threshold})]\n${current.feedback}\n\n[현재 보고서]\n${bestDraft}`,
model: params.model,
temperature: 0.4,
maxTokens: getConfig().maxOutputTokens,
timeoutMs: 180_000,
signal: params.signal,
});
revised = (res.content || '').trim();
} catch (e: any) {
logWarn('[ReportQA] 재작성 호출 실패 — 현재 최고 초안 유지.', { error: e?.message || String(e) });
break;
}
if (!revised || revised.length < bestDraft.length * 0.4) {
// 재작성이 비었거나 심하게 잘림 — 회귀로 간주하고 폐기.
logWarn('[ReportQA] 재작성본이 비었거나 과도하게 짧음 — 폐기.', { revisedChars: revised.length, bestChars: bestDraft.length });
continue;
}
const rescored = await scoreOnce({ draft: revised, prompt: params.prompt, intentText, model: params.model, signal: params.signal });
if (!rescored) break;
trajectory.push(rescored.score);
if (rescored.score >= bestScore) {
bestDraft = revised;
bestScore = rescored.score;
current = rescored;
logInfo('[ReportQA] 재작성 채택.', { round: revisions, score: rescored.score });
} else {
// 회귀 게이트 — 편집이 점수를 떨어뜨리면 그 편집본은 버린다 (블로그_v3 의 85→79 사고 방지).
logInfo('[ReportQA] 재작성본 점수 하락 — 회귀 게이트로 폐기.', { round: revisions, was: bestScore, got: rescored.score });
}
}
return { finalDraft: bestDraft, score: bestScore, revisions, trajectory };
}
+27 -4
View File
@@ -143,6 +143,16 @@ export interface IAgentConfig {
* 깨끗한 답변에는 안 돈다 (latency 보호). 기본 true. (Phase 1 / Track 2-3)
*/
criticLoopEnabled: boolean;
/**
* Report QA Loop — 조사·보고서형 워크플로우 답변에 블로그_v3 방식 품질 루프 적용:
* 의도 브리핑(왜 묻는지 분석) → 초안 → QA 채점(규칙 레지스트리+실측치 주입) →
* 피드백 재작성 → 회귀 게이트(점수 하락 시 재작성본 폐기). 기본 true. (v2.2.301)
*/
reportQaEnabled: boolean;
/** Report QA 통과 점수 임계 (이 미만이면 재작성). 기본 80. */
reportQaThreshold: number;
/** Report QA 최대 재작성 횟수 (로컬 모델 latency 보호). 기본 2. */
reportQaMaxRevisions: number;
/**
* Reflection — 업무 turn 의 결정론적 회고(확신도·누락 요소·에스컬레이션)를
* <brain>/.astra/growth/reflections.jsonl 에 기록. 성장 추이·반복 실수 집계의
@@ -394,8 +404,8 @@ export interface IAgentConfig {
// ─── Stream 표시 ───
/**
* 모델 토큰을 받는 즉시 채팅 버블에 흘려보낼지 여부.
* - false(기본): 토큰은 내부에서만 누적, sanitize 끝난 최종 답변만 한 번에 표시 → Harmony/think 마커 누설 원천 차단.
* - true: legacy 라이브 스트리밍. 모델 출력에 control token 이 섞여 나오면 잠깐 화면에 보일 수 있음.
* - true(기본): 실시간 표시. 추론 구간(<think>/Harmony thought)은 LiveReasoningFilter 가 토큰 단위로 차단.
* - false: 토큰은 내부에서만 누적, sanitize 끝난 최종 답변만 한 번에 표시.
*/
liveStreamTokens: boolean;
/**
@@ -460,6 +470,15 @@ function toBrainProfile(raw: Partial<BrainProfile> | undefined, fallbackIndex: n
}
// ─── VS Code 설정에서 읽어오는 값 (통합 구현) ───
//
// [설정 접근 규칙 — 2026-07-11 아키텍처 감사에서 명문화]
// IAgentConfig 에 이미 있는 값(엔진 URL·모델·컨텍스트 한계·두뇌 프로필 등
// "코어 계약"에 속하는 값)은 반드시 getConfig() 를 통해 읽는다 — 기본값·클램프·
// 레거시 마이그레이션이 여기 한 곳에 있기 때문. 반대로 특정 기능 전용 설정
// (예: g1nation.stocks.*, dailyBriefing.time)은 그 기능 파일에서
// vscode.workspace.getConfiguration('g1nation') 직접 접근이 허용된다 —
// 기능 하나 추가할 때마다 이 인터페이스를 비대하게 만들지 않기 위한 의도적 분리.
// 판단 기준: "둘 이상의 모듈이 읽는 값인가?" → 그렇다면 IAgentConfig 로 승격할 것.
export function getConfig(): IAgentConfig {
const cfg = vscode.workspace.getConfiguration('g1nation');
@@ -535,9 +554,13 @@ export function getConfig(): IAgentConfig {
requirementGraphEnabled: cfg.get<boolean>('requirementGraphEnabled', true),
requirementCoverageEnabled: cfg.get<boolean>('requirementCoverageEnabled', true),
epistemicGuardEnabled: cfg.get<boolean>('epistemicGuardEnabled', true),
confidenceEngineEnabled: cfg.get<boolean>('confidenceEngineEnabled', true),
escalationEnabled: cfg.get<boolean>('escalationEnabled', true),
// v2.2.300: footer 기본 비표시 (사용자 피드백) — 계산·Reflection 은 항상 수행.
confidenceEngineEnabled: cfg.get<boolean>('confidenceEngineEnabled', false),
escalationEnabled: cfg.get<boolean>('escalationEnabled', false),
criticLoopEnabled: cfg.get<boolean>('criticLoopEnabled', true),
reportQaEnabled: cfg.get<boolean>('reportQaEnabled', true),
reportQaThreshold: Math.max(50, Math.min(95, cfg.get<number>('reportQaThreshold', 80))),
reportQaMaxRevisions: Math.max(0, Math.min(5, cfg.get<number>('reportQaMaxRevisions', 2))),
reflectionEnabled: cfg.get<boolean>('reflectionEnabled', true),
orgMemoryEnabled: cfg.get<boolean>('orgMemoryEnabled', true),
actionabilityEnabled: cfg.get<boolean>('actionabilityEnabled', true),
+3 -6
View File
@@ -2,11 +2,8 @@ import * as vscode from 'vscode';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { exec } from 'child_process';
import { promisify } from 'util';
import { getConfig } from '../config';
const execAsync = promisify(exec);
import { execShell } from '../lib/execUtil';
import { logInfo, logWarn, logError, getActiveBrainProfile } from '../utils';
import { getBridgeBaseUrl } from '../features/datacollect/bridgeClient';
@@ -111,10 +108,10 @@ export class HealthCheckMonitor {
try {
if (brain?.secondBrainRepo?.trim() && brain.localBrainPath && fs.existsSync(brain.localBrainPath)) {
try {
await execAsync('git push --dry-run', {
await execShell('git push --dry-run', {
cwd: brain.localBrainPath,
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
timeout: 5000,
timeoutMs: 5000,
});
} catch (e: any) {
const msg = String(e?.stderr || e?.message || '');
+44 -2
View File
@@ -26,6 +26,12 @@ export interface AIChatRequest {
model?: string;
/** Optional override (default = config.timeout). */
timeoutMs?: number;
/** 샘플링 온도 (기본 0.7). 판정·재순위 등 결정적 작업은 0.0~0.2 권장. */
temperature?: number;
/** 출력 토큰 상한 (ollama num_predict / lmstudio max_tokens). 미지정 시 서버 기본. */
maxTokens?: number;
/** [ollama 전용] 컨텍스트 창 크기(num_ctx). */
numCtx?: number;
/**
* 외부 abort signal. fetch 가 받는 signal 과 OR 로 결합되어, 사용자가 회사 모드
* 도중 Stop 을 누르면 진행 중인 generation 이 즉시 중단된다. 없으면 timeout 만
@@ -37,7 +43,7 @@ export interface AIChatRequest {
export interface AIChatResult {
content: string;
/** Engine that actually returned the content. */
engine: 'lmstudio' | 'ollama';
engine: 'lmstudio' | 'ollama' | 'claude-code';
model: string;
/** True iff content came back empty after all retries. Caller decides UX. */
empty: boolean;
@@ -73,6 +79,23 @@ export class AIService implements IAIService {
const config = getConfig();
const model = (req.model || config.defaultModel || '').trim() || 'gemma4:e2b';
const timeoutMs = req.timeoutMs ?? config.timeout;
// [v2.2.305] Claude 구독 모델 ('claude:sonnet' 등) — 로컬 엔진 대신 Claude Code CLI 위임.
// 워크플로우·Report QA·리랭크 등 coreChat 소비자 전부가 구독 모델로 동작 가능해진다.
// (다른 클라우드 prefix 는 스트리밍 전용 경로만 지원 — 여기 오면 아래 로컬 시도가 실패로 드러남)
if (model.startsWith('claude:')) {
const { runClaudeCode } = await import('../features/providers/claudeCode');
const messages: Array<{ role: 'system' | 'user' | 'assistant'; content: string }> = [];
if (req.system && req.system.trim()) messages.push({ role: 'system', content: req.system });
messages.push({ role: 'user', content: req.user });
const r = await runClaudeCode({
messages,
model: model.slice('claude:'.length),
signal: req.signal,
timeoutMs: Math.max(timeoutMs, 120_000),
});
return { content: r.text, engine: 'claude-code', model, empty: !r.text.trim() };
}
const primaryEngine = resolveEngine(config.ollamaUrl);
const engines = primaryEngine === 'lmstudio'
? ['lmstudio', 'ollama'] as const
@@ -89,11 +112,21 @@ export class AIService implements IAIService {
for (const engine of engines) {
const apiUrl = buildApiUrl(config.ollamaUrl, engine, 'chat');
const temperature = req.temperature ?? 0.7;
const payload = {
model,
messages,
stream: false,
...(engine === 'ollama' ? { options: { temperature: 0.7 } } : { temperature: 0.7 }),
...(engine === 'ollama'
? { options: {
temperature,
...(req.maxTokens != null ? { num_predict: req.maxTokens } : {}),
...(req.numCtx != null ? { num_ctx: req.numCtx } : {}),
} }
: {
temperature,
...(req.maxTokens != null ? { max_tokens: req.maxTokens } : {}),
}),
};
try {
@@ -151,6 +184,15 @@ export class AIService implements IAIService {
}
}
/**
* [코어 수렴] 인스턴스 없이 코어 LLM 경로를 쓰는 모듈 함수용 헬퍼.
* AIService 는 무상태(설정을 매 호출 읽음)라 안전하다. 엔진 폴백·타임아웃·abort·
* 로깅·빈응답 소프트실패가 모두 이 한 경로로 통일된다 — 자체 fetch 금지.
*/
export function coreChat(req: AIChatRequest): Promise<AIChatResult> {
return new AIService().chat(req);
}
/**
* BrainService: 지식 베이스 파일 시스템 저장 및 관리 구현체
*/
+1
View File
@@ -5,6 +5,7 @@ import * as path from 'path';
// 가 이미 로드된 후 등록되도록 entry point 에서. (v2.2.196~201 도메인별 파일 분리)
import './features/teamops/handlers';
import './features/system/handlers';
import './features/claude/claudeLauncher'; // /claude — Claude Code CLI 위임 (v2.2.303)
import './features/datacollect/handlers';
import './features/email/handlers'; // /email — Gmail + Outlook (v2.2.269)
// axios removed in favor of native fetch
+2 -18
View File
@@ -12,6 +12,7 @@
* 스케줄링은 stocksWatcher 와 동일한 단일 setTimeout 체인 패턴 (KST 고정).
*/
import * as vscode from 'vscode';
import { nowInKst, parseHhMm } from '../../lib/kstSchedule';
import { logError, logInfo } from '../../utils';
import { TelegramHttpClient } from '../../integrations/telegram/telegramClient';
import { TELEGRAM_TOKEN_SECRET_KEY } from '../../extension/telegramCommands';
@@ -22,25 +23,8 @@ let _timer: NodeJS.Timeout | undefined;
let _disposed = false;
let _lastFiredYmd = ''; // 같은 날 중복 발송 방지 (타이머 드리프트 대비)
function nowInKst(): { hour: number; minute: number; ymd: string; weekday: number } {
const now = new Date();
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(now);
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 가 그 날짜의 요일 (0=일).
const weekday = new Date(`${ymd}T00:00:00Z`).getUTCDay();
return { hour: Number(get('hour')), minute: Number(get('minute')), ymd, weekday };
}
function briefingTime(): { hour: number; minute: number } {
const raw = (vscode.workspace.getConfiguration('g1nation').get<string>('dailyBriefing.time', '09:30') || '09:30').trim();
const m = raw.match(/^(\d{1,2}):(\d{2})$/);
if (!m) return { hour: 9, minute: 30 };
return { hour: Math.min(23, Number(m[1])), minute: Math.min(59, Number(m[2])) };
return parseHhMm(vscode.workspace.getConfiguration('g1nation').get<string>('dailyBriefing.time', '09:30'), 9, 30);
}
/** 다음 발송까지 ms — 평일 스킵 포함. */
+60
View File
@@ -0,0 +1,60 @@
import * as vscode from 'vscode';
import { registerSlashCommand, chunk, type Webview } from '../datacollect/slashRouter';
/**
* /claude — 로컬에 설치된 Claude Code CLI 를 VS Code 통합 터미널에서 실행.
*
* 배경 (v2.2.303): 벤치마킹→개발 파이프라인의 마지막 구간. 로컬 소형 모델은
* 분석·명세(핸드오프 문서)까지 담당하고, 실제 코드 생성은 클라우드 모델에
* 위임한다. Anthropic 구독(Pro/Max) 인증은 API 키가 아니라 Claude Code 로그인
* 방식이므로, Astra 가 API 를 직접 호출하는 대신 **사용자가 이미 로그인해 둔
* `claude` CLI 를 실행**한다 — 추가 과금 없이 구독 한도 안에서 동작.
*
* 설계 (bridgeLauncher 의 터미널 패턴 재사용):
* - 통합 터미널("Claude Code (Astra)")에서 실행 → 셸/OS 차이를 VS Code 가 흡수,
* Claude Code 의 권한 승인·진행 상황을 사용자가 눈으로 보며 제어.
* - cwd = 첫 워크스페이스 폴더 (Claude Code 가 그 프로젝트 컨텍스트로 동작).
* - 지시문은 첫 인자로 전달 — 따옴표는 셸 호환을 위해 정규화.
*/
const TERMINAL_NAME = 'Claude Code (Astra)';
/** 셸(zsh/bash/PowerShell) 공통으로 안전한 한 줄 인자로 정규화 (순수 — 테스트 대상). */
export function buildClaudeCommand(instruction: string): string {
const t = (instruction || '').trim();
if (!t) return 'claude';
// 큰따옴표로 감싸므로 내부의 "는 ', POSIX 확장 문자와 PowerShell 백틱은 공백으로 완화.
// 지시문은 터미널에서 사용자가 이어서 수정할 수 있으니 완벽 이스케이프보다 안전 우선.
const safe = t.replace(/"/g, "'").replace(/[`$\\]/g, ' ').replace(/\s+/g, ' ').trim();
return `claude "${safe}"`;
}
async function runClaude(arg: string, view: Webview | undefined): Promise<boolean> {
const instruction = (arg || '').trim();
const ws = vscode.workspace.workspaceFolders?.find(f => f.uri.scheme === 'file');
const cwd = ws?.uri.fsPath;
// 기존 우리 터미널이 있으면 재사용하지 않고 정리 후 새로 연다 (죽은 셸 방지 — bridgeLauncher 동일).
vscode.window.terminals.find(t => t.name === TERMINAL_NAME)?.dispose();
const terminal = vscode.window.createTerminal({ name: TERMINAL_NAME, cwd });
terminal.show(true);
terminal.sendText(buildClaudeCommand(instruction));
chunk(view, [
`🚀 **Claude Code 실행** — "${TERMINAL_NAME}" 터미널에서 진행됩니다.`,
instruction ? `- 전달한 지시: ${instruction.slice(0, 200)}${instruction.length > 200 ? '…' : ''}` : '- 지시 없이 대화형으로 시작했습니다.',
cwd ? `- 작업 폴더: \`${cwd}\`` : '- ⚠️ 열린 워크스페이스가 없어 홈 폴더에서 시작합니다.',
'- 파일 수정·명령 실행 승인은 터미널에서 직접 하시면 됩니다 (구독 인증 그대로 사용).',
'',
'💡 `command not found: claude` 가 뜨면: 터미널에서 `npm install -g @anthropic-ai/claude-code` 설치 후 `claude` 를 실행해 로그인하세요.',
'',
].join('\n'));
return true;
}
registerSlashCommand({
name: '/claude',
description: 'Claude Code(클라우드·구독 인증)를 터미널로 실행 — 핸드오프 문서 기반 개발 위임. 예: /claude <파일경로> 읽고 명세대로 개발해줘',
handler: runClaude,
});
+16 -4
View File
@@ -161,6 +161,12 @@ export type CompanyTurnEmitter = (event: CompanyTurnEvent) => void;
export interface DispatcherDeps {
context: vscode.ExtensionContext;
ai: IAIService;
/**
* [코어 수렴] 두뇌 컨텍스트 검색 thunk — 있으면 메인 RetrievalOrchestrator 의
* 전체 검색 경로(임베딩 하이브리드·청크)를 사용한다. 없으면(텔레그램 등 경량
* 환경) 기존 scopedBrainRetriever 로 폴백. 반환 포맷은 buildContextBlock 동일.
*/
retrieveBrainContext?: (query: string, scopeFolders: string[], limit: number) => Promise<string>;
/** Default model to fall back to when an agent has no override. */
defaultModel: string;
/**
@@ -667,10 +673,16 @@ async function _dispatchOne(
// the whole brain so a missing mapping doesn't starve the
// dispatcher.
const scope = resolveScopeForAgent(agentId, brainRoot);
const retrieval = retrieveScoped(task, brainRoot, scope.folders, {
maxResults: brainFileLimit,
});
brainContext = buildContextBlock(retrieval);
if (deps.retrieveBrainContext) {
// 메인 오케스트레이터 경로 — 임베딩 하이브리드·청크·개선 사항을 그대로 수혜.
brainContext = await deps.retrieveBrainContext(task, scope.folders, brainFileLimit);
} else {
// 경량 폴백 (텔레그램 등 오케스트레이터가 없는 소비자).
const retrieval = retrieveScoped(task, brainRoot, scope.folders, {
maxResults: brainFileLimit,
});
brainContext = buildContextBlock(retrieval);
}
}
} catch (e: any) {
logError('company.dispatcher: RAG retrieval failed; continuing without brain context.', {
+2 -5
View File
@@ -1,10 +1,7 @@
import * as vscode from 'vscode';
import * as fs from 'fs';
import * as path from 'path';
import { exec } from 'child_process';
import { promisify } from 'util';
const execAsync = promisify(exec);
import { execShell } from '../../lib/execUtil';
/**
* NotebookLM 로컬 백엔드(Datacollect Bridge) 원클릭 실행/종료.
@@ -133,7 +130,7 @@ export async function stopNotebookLmBridge(): Promise<void> {
await new Promise(r => setTimeout(r, 600)); // 터미널 dispose 가 프로세스를 정리할 시간
if (await isBridgeAlive()) {
try {
await execAsync(buildPortKillCommand(process.platform, BRIDGE_PORT), { timeout: 8000 });
await execShell(buildPortKillCommand(process.platform, BRIDGE_PORT), { timeoutMs: 8000 });
} catch { /* 이미 죽었거나 권한 문제 — 아래 재확인으로 판정 */ }
}
+119 -13
View File
@@ -15,10 +15,15 @@ import { promises as fsp } from 'fs';
import * as path from 'path';
import { registerSlashCommand, chunk, type Webview } from './slashRouter';
import { callLmSynthesis } from './llm';
import { getConfig } from '../../config';
import { logInfo } from '../../utils';
import { runReportQaLoop } from '../../agents/reportQa';
import { bridgeFetch, BRIDGE_API } from './bridgeClient';
import { collectSourceFiles } from './reviewFiles';
import { buildReviewFilePrompt, buildReviewReducePrompt } from './prompts/reviewPrompt';
import { type SynthesisPart, buildSynthesisPrompt } from './prompts/synthesisPrompt';
import { buildHandoffDoc } from './prompts/handoffDoc';
import { buildLayoutSkeleton } from './prompts/layoutSkeleton';
import {
type YoutubeAnalysisMode,
formatHms,
@@ -49,28 +54,64 @@ import {
// ───────────────────────────── /benchmark ─────────────────────────────
async function runBenchmark(arg: string, view: Webview | undefined): Promise<boolean> {
/**
* /benchmark 모드 → 실행할 합성 파트 목록.
* - full(기본): 디자인 3파트 + 비즈니스 렌즈 (1~4)
* - design: 기존 3파트만 (원본 복원 시방서)
* - biz: 비즈니스 렌즈만 (빠름 — 포지셔닝·가격·운영)
* - build: 풀코스 + 스텝별 빌드 프롬프트 (1~5)
*/
export type BenchmarkMode = 'full' | 'design' | 'biz' | 'build' | 'handoff';
export const BENCHMARK_MODE_PARTS: Record<BenchmarkMode, ReadonlyArray<1 | 2 | 3 | 4 | 5>> = {
full: [1, 2, 3, 4],
design: [1, 2, 3],
biz: [4],
build: [1, 2, 3, 4, 5],
handoff: [], // LLM 합성 없음 — 스캔 원시 데이터로 인수인계 문서를 결정론 생성
};
/** /benchmark 인자 파싱 — 순수 함수 (테스트 대상). */
export function parseBenchmarkArgs(arg: string): {
url: string; depth?: number; pages?: number; mode: BenchmarkMode; userContent: string;
} {
const tokens = arg.trim().split(/\s+/).filter(Boolean);
let url = '';
let depthArg: number | undefined;
let pagesArg: number | undefined;
let depth: number | undefined;
let pages: number | undefined;
let mode: BenchmarkMode = 'full';
const restParts: string[] = [];
const BARE_MODES = new Set<BenchmarkMode>(['full', 'design', 'biz', 'build', 'handoff']);
for (const t of tokens) {
const m = /^(depth|pages)=(\d+)$/i.exec(t);
const modeM = /^mode[:=](full|design|biz|build|handoff)$/i.exec(t);
if (m) {
if (m[1].toLowerCase() === 'depth') depthArg = Number(m[2]);
else pagesArg = Number(m[2]);
if (m[1].toLowerCase() === 'depth') depth = Number(m[2]);
else pages = Number(m[2]);
} else if (modeM) {
mode = modeM[1].toLowerCase() as BenchmarkMode;
} else if (BARE_MODES.has(t.toLowerCase() as BenchmarkMode)) {
mode = t.toLowerCase() as BenchmarkMode;
} else if (!url) {
url = t;
} else {
restParts.push(t);
}
}
return { url, depth, pages, mode, userContent: restParts.join(' ') };
}
async function runBenchmark(arg: string, view: Webview | undefined): Promise<boolean> {
const { url, depth: depthArg, pages: pagesArg, mode, userContent } = parseBenchmarkArgs(arg);
if (!url) {
chunk(view, `사용법: \`/benchmark <url> [depth=N] [pages=N] [보조 설명]\`\n예: \`/benchmark https://example.com depth=2 pages=12\`\n`);
chunk(view, [
'사용법: `/benchmark <url> [mode] [depth=N] [pages=N] [보조 설명]`',
'- mode: `full`(기본 — 디자인+비즈니스) · `design`(복원 시방서만) · `biz`(사업 분석만) · `build`(풀코스+빌드 프롬프트) · `handoff`(클라우드 개발용 인수인계 문서 — LLM 없이 즉시)',
'- 가격·수익 분석이 목적이면 `depth=2` 로 pricing 페이지까지 크롤 권장',
'예: `/benchmark https://interiorai.com build depth=2 pages=12 나는 부동산 쪽으로 변형하고 싶어`',
'',
].join('\n'));
return true;
}
const userContent = restParts.join(' ');
const cfg = vscode.workspace.getConfiguration('g1nation');
const crawlDepth = depthArg ?? (cfg.get<number>('datacollectCrawlDepth', 1) ?? 1);
@@ -126,23 +167,84 @@ async function runBenchmark(arg: string, view: Webview | undefined): Promise<boo
'```',
].join('\n');
// ── handoff 모드: LLM 합성을 건너뛰고 스캔 원시 데이터로 인수인계 문서를 즉시 생성.
// 소형 모델의 요약 병목을 통과시키지 않는다 (측정치 무손실 — handoffDoc.ts 참조).
if (mode === 'handoff') {
const handoffDoc = buildHandoffDoc(s, url, { crawlDepth, maxPages, userContent });
chunk(view, `📦 **개발 인수인계 문서 생성** (LLM 미개입 — 측정치 원본)\n\n`);
const hToday = new Date().toISOString().slice(0, 10);
let hHost = url;
try { hHost = new URL(/^https?:\/\//i.test(url) ? url : `https://${url}`).host; } catch { /* keep raw */ }
const hTitle = `웹벤치마크 핸드오프 ${hHost} ${hToday}`;
const savedH = await persistWikiDoc(hTitle, handoffDoc, { docType: 'benchmark-handoff', sources: [url] });
chunk(view, describeSave(savedH, '인수인계 문서'));
if (savedH.ok && savedH.path) {
chunk(view, [
'',
'**다음 단계 — 클라우드 모델로 개발:**',
`1. 파일을 열어 §1 "내 요구사항" 을 채우세요 (무엇으로 변형할지).`,
`2. Claude Code(구독 인증)로 바로 개발: \`/claude ${savedH.path} 파일을 읽고 명세대로 개발해줘\``,
` (또는 Antigravity 에 파일 내용을 붙여넣어도 됩니다)`,
'',
].join('\n'));
}
return true;
}
let finalReport: string;
if (looksEmpty) {
chunk(view, `(스캔이 비어 LLM 합성을 건너뜁니다.)\n\n`);
finalReport = rawReport;
} else {
const model = (cfg.get<string>('defaultModel', '') || 'gemma4:e2b').trim();
chunk(view, `🧪 **LLM 4-렌즈 합성** (3단계 · 모델 \`${model}\`)\n모델·하드웨어에 따라 수 분 걸릴 수 있습니다…\n`);
const partList = BENCHMARK_MODE_PARTS[mode];
const PART_LABELS: Record<number, string> = {
1: '4-렌즈 디자인', 2: 'IA·토큰', 3: '재구축 명세', 4: '비즈니스 렌즈', 5: '빌드 프롬프트',
};
chunk(view, `🧪 **LLM 합성** (mode \`${mode}\` · ${partList.length}단계 · 모델 \`${model}\`)\n모델·하드웨어에 따라 수 분 걸릴 수 있습니다…\n`);
try {
const parts: string[] = [];
for (const part of [1, 2, 3] as const) {
chunk(view, `\n · 합성 ${part}/3 진행 중…`);
let okParts = 0;
for (let i = 0; i < partList.length; i++) {
const part = partList[i];
chunk(view, `\n · ${i + 1}/${partList.length} ${PART_LABELS[part]} 합성 중…`);
const partT0 = Date.now();
const out = await callLmSynthesis(buildSynthesisPrompt(s, userContent, part));
if (!out) throw new Error(`LLM ${part}/3 응답이 비어 있습니다.`);
// [v2.2.307] 파트별 실패 격리 — 한 파트가 죽어도 완성된 파트를 버리지 않는다
// (실사례: 파트 2 타임아웃으로 105초 걸린 파트 1까지 폐기). 타임아웃도 300초로 —
// 입력 JSON 이 큰 파트는 e4b 급에서 120초를 넘긴다.
let out: string;
try {
out = await callLmSynthesis(buildSynthesisPrompt(s, userContent, part), undefined, { timeoutMs: 300_000 });
} catch (partErr: any) {
chunk(view, ` ✗ (${Math.round((Date.now() - partT0) / 1000)}s — ${partErr?.message || partErr})`);
parts.push(`> ⚠️ **${PART_LABELS[part]} 파트 합성 실패**: ${partErr?.message || String(partErr)} — 나머지 파트는 계속 진행됨.`);
continue;
}
if (!out) {
chunk(view, ` ✗ (빈 응답)`);
parts.push(`> ⚠️ **${PART_LABELS[part]} 파트 합성 실패**: 빈 응답 — 나머지 파트는 계속 진행됨.`);
continue;
}
okParts++;
// [Report QA] 비즈니스 렌즈는 날조 위험(임의 가격·매출)이 가장 큰 파트 —
// 채점→피드백 재작성→회귀 게이트를 통과시킨다. prompt 로 합성 프롬프트
// 자체(스캔 JSON 포함)를 넘겨 재작성이 원본 근거를 보게 한다.
if (part === 4 && getConfig().reportQaEnabled) {
chunk(view, ` (품질 검증 중…)`);
const qa = await runReportQaLoop({
draft: out,
prompt: buildSynthesisPrompt(s, userContent, 4),
intentBrief: null,
model,
onProgress: (msg: string) => chunk(view, ` · ${msg}`),
});
out = qa.finalDraft;
logInfo('[Benchmark] 비즈니스 렌즈 QA 완료.', { score: qa.score, revisions: qa.revisions });
}
parts.push(out);
chunk(view, ` ✓ (${Math.round((Date.now() - partT0) / 1000)}s)`);
}
if (okParts === 0) throw new Error('모든 합성 파트가 실패했습니다.');
finalReport = parts.join('\n\n---\n\n');
chunk(view, `\n\n`);
} catch (e: any) {
@@ -151,6 +253,10 @@ async function runBenchmark(arg: string, view: Webview | undefined): Promise<boo
}
}
// [v2.2.307] 레이아웃 골격 (결정론) — 합성 성공/실패와 무관하게 항상 포함.
// "배너/캐러셀이 있는가, 각 페이지가 어떤 블록 순서로 구성되는가"에 즉답.
finalReport = `${buildLayoutSkeleton(s)}\n\n---\n\n${finalReport}`;
chunk(view, finalReport + '\n\n');
try {
@@ -1129,7 +1235,7 @@ async function reduceReviewNotes(
// /research(NotebookLM Deep Research)는 v2.2.205 에서 제거 — NotebookLM 은 로컬
// Datacollect 앱 전용으로 분리(Chrome/Google 로그인 의존). ASTRA 백엔드는 NAS 경량
// Bridge 로 운영 가능해야 하므로 brower-auth 가 필요한 명령은 두지 않는다.
registerSlashCommand({ name: '/benchmark', description: 'Playwright 웹 벤치마크 + 4-렌즈 LLM 분석', handler: runBenchmark });
registerSlashCommand({ name: '/benchmark', description: '웹 벤치마크 — 디자인 4-렌즈 + 비즈니스 렌즈(가격·수익·운영) 분석, build 모드는 스텝별 빌드 프롬프트까지', handler: runBenchmark });
registerSlashCommand({ name: '/youtube', description: 'YouTube 단일 영상 또는 채널/플레이리스트 분석', handler: runYoutube });
registerSlashCommand({ name: '/blog', description: 'Blog Pipeline 안내 (Datacollect 별도 흐름)', handler: runBlog });
registerSlashCommand({ name: '/wikify', description: '웹 URL → P-Reinforce v3.0 위키 합성·저장', handler: runWikify });
+11 -3
View File
@@ -21,6 +21,10 @@ import * as vscode from 'vscode';
* LM 서버(OpenAI 호환 `/v1/chat/completions`)를 확장에서 직접 호출. LM Studio/Ollama 는
* 인증이 없으므로 토큰 불필요. 타임아웃 가드 포함, 비정상 응답이면 throw.
*/
// [코어 수렴 예외 — 의도적] 이 모듈은 core/services.coreChat 으로 수렴하지 않는다:
// finish_reason 기반 이어쓰기(continuation), repeat_penalty/top_k 커스텀 샘플링,
// degeneration 감지·재시도 등 '장문 생성 엔진' 요구가 코어 채팅 API 범위를 벗어난다.
// 단순 단발 완성이 필요한 새 코드는 이 파일이 아니라 coreChat 을 사용할 것.
async function lmChat(lmUrl: string, payload: unknown, timeoutMs = 120_000): Promise<any> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
@@ -70,6 +74,9 @@ interface LmOpts {
/** 출력이 length(토큰 상한)로 잘렸을 때 끊긴 지점부터 이어쓰기 최대 횟수(기본 3). 0이면 이어쓰기 안 함.
* 회의록 "누락" 의 실제 원인(출력 잘림)을 보완 — 잘린 부분을 이어붙여 완결성을 확보. */
maxContinuations?: number;
/** 호출당 타임아웃(ms). 기본 120초 — /benchmark 4파트 합성처럼 입력 JSON 이 큰
* 호출은 e4b 급 모델에서 120초를 넘길 수 있어 (실사례: 파트 2 abort) 크게 지정. */
timeoutMs?: number;
}
/** 단발 호출 결과 — 이어쓰기 판단을 위해 finish_reason 을 함께 반환. */
@@ -82,6 +89,7 @@ async function callLmOnce(
lmUrl: string, model: string, sys: string, prompt: string,
sampling: { temperature: number; repeat_penalty: number; top_k: number },
maxTokens?: number,
timeoutMs = 120_000,
): Promise<LmResult> {
const payload: Record<string, unknown> = {
model,
@@ -96,7 +104,7 @@ async function callLmOnce(
};
// max_tokens 는 지정됐을 때만 넣는다 — 미지정 caller(벤치마크 등)의 기존 동작(서버 기본값)을 바꾸지 않기 위해.
if (maxTokens && maxTokens > 0) payload.max_tokens = maxTokens;
const res = await lmChat(lmUrl, payload, 120_000);
const res = await lmChat(lmUrl, payload, timeoutMs);
const choice = res?.choices?.[0];
const content = choice?.message?.content ?? choice?.text ?? res?.answer ?? res?.response ?? '';
const finish = String(choice?.finish_reason ?? choice?.native_finish_reason ?? '').toLowerCase();
@@ -146,7 +154,7 @@ export async function callLmSynthesis(prompt: string, systemPrompt?: string, opt
top_k: Math.max(10, 20 - attempt * 5), // 20 → 15 → 10
};
try {
const first = await callLmOnce(lmUrl, model, sys, prompt, sampling, opts?.maxTokens);
const first = await callLmOnce(lmUrl, model, sys, prompt, sampling, opts?.maxTokens, opts?.timeoutMs);
let out = first.text;
if (!out) { lastErr = new Error('LLM 응답이 비어 있습니다.'); continue; }
if (looksDegenerate(out)) {
@@ -159,7 +167,7 @@ export async function callLmSynthesis(prompt: string, systemPrompt?: string, opt
for (let c = 0; c < maxCont && finish === 'length'; c++) {
let next: LmResult;
try {
next = await callLmOnce(lmUrl, model, sys, buildContinuePrompt(out.slice(-1600)), sampling, opts?.maxTokens);
next = await callLmOnce(lmUrl, model, sys, buildContinuePrompt(out.slice(-1600)), sampling, opts?.maxTokens, opts?.timeoutMs);
} catch { break; } // 이어쓰기 호출 실패 시 지금까지분으로 마감
if (!next.text || looksDegenerate(next.text)) break; // 이어쓰기가 붕괴하면 중단
out = `${out}\n${next.text}`.trim();
@@ -0,0 +1,147 @@
/**
* /benchmark handoff — 클라우드 모델(Claude Code·Antigravity 등) 개발 인수인계 문서.
*
* 설계 원칙 (v2.2.303): 로컬 소형 모델의 *작문*을 통과시키지 않는다.
* 4b 모델이 큰 모델에게 줄 지시문을 요약하게 하면 스캔이 정확히 측정한 값
* (rgb·px·카피 원문)이 "적절한 색상" 같은 모호어로 퇴화한다. 클라우드 모델은
* 원시 구조화 데이터를 그대로 소화하는 걸 가장 잘하므로, 이 문서는 스캔 JSON 을
* 결정론적으로 추려 옮기는 **순수 함수**다 — LLM 호출 0회, 손실 0.
*
* 산출물 구성: ①클라우드 모델용 지시문 ②내 요구사항 슬롯(사용자 변형 지시)
* ③디자인 토큰 원본 ④마이크로카피 원문 ⑤IA/페이지 인벤토리 ⑥원시 JSON 부록.
*/
const j = (v: unknown): string => JSON.stringify(v, null, 2);
/** 값이 있으면 "- **라벨**: 값" 한 줄, 없으면 빈 문자열. */
function line(label: string, v: unknown): string {
if (v === undefined || v === null || v === '' || (Array.isArray(v) && v.length === 0)) return '';
const s = Array.isArray(v) ? v.map(x => typeof x === 'object' ? JSON.stringify(x) : String(x)).join(', ') : (typeof v === 'object' ? JSON.stringify(v) : String(v));
return `- **${label}**: ${s}`;
}
function section(title: string, lines: string[]): string {
const body = lines.filter(Boolean).join('\n');
return body ? `### ${title}\n${body}` : `### ${title}\n- (스캔 데이터 없음)`;
}
export function buildHandoffDoc(scan: any, url: string, opts: { crawlDepth: number; maxPages: number; userContent: string }): string {
const s = scan || {};
const colors = s.design?.colors || {};
const typo = s.design?.typography || {};
const layout = s.design?.layout || {};
const inter = s.interactions || {};
const micro = s.microcopy || {};
const pages: any[] = s.sitemap?.pages || [];
const pageRows = pages.map((p: any) => {
const forms = (p.formFields || []).slice(0, 4).map((f: any) => f.name || f.label || f.type).filter(Boolean).join('·') || '-';
const ctas = (p.ctaSamples || []).slice(0, 3).join(' / ') || '-';
return `| ${p.url || '-'} | ${p.role || '-'} | ${(p.h1 || p.title || '-').slice(0, 40)} | ${p.primaryContentType || '-'} | ${p.imageCount ?? '-'} | ${forms} | ${ctas} |`;
}).join('\n');
const requirements = opts.userContent.trim()
? `사용자가 지정한 변형 방향:\n> ${opts.userContent.trim()}\n\n위 방향에 맞춰 아래 원본 명세를 변형하되, 디자인 토큰·레이아웃 골격은 유지하라.`
: [
'아직 지정되지 않음 — 개발 시작 전에 아래를 채워 넣을 것:',
'- [ ] 서비스 이름 / 도메인:',
'- [ ] 헤드라인을 무엇으로 바꿀지 (원본의 포지셔닝 공식은 유지):',
'- [ ] 콘텐츠·이미지는 무엇으로 대체할지:',
'- [ ] 뺄 기능 (결제·로그인 등 MVP 에서 제외할 것):',
'- [ ] 핵심 생성/처리 엔진 (사용할 API 또는 로컬 모델):',
].join('\n');
return `# 개발 인수인계 — ${s.meta?.title || url}
> 이 문서는 Playwright 스캔이 **측정한 원시 값**을 그대로 옮긴 개발 명세입니다.
> 생성: Astra /benchmark handoff · 원본 ${url} · 스캔 ${s.sitemap?.totalPages ?? 1}페이지 (depth ${opts.crawlDepth})
## 0. 클라우드 모델용 지시문 (이 문서를 받은 AI 에게)
너는 아래 명세를 근거로 원본 사이트와 매우 유사한 사이트를 처음부터 구현한다.
1. **모든 색상·폰트·여백·radius 값은 이 문서의 측정치를 그대로 사용하라** — "비슷한 색", "모던한 폰트" 같은 임의 대체 금지.
2. §1 "내 요구사항"의 변경 지시가 원본 명세와 충돌하면 요구사항이 우선한다. 그 외에는 원본 재현이 기본.
3. 페이지 구조는 §4 인벤토리의 역할(role)·섹션 순서를 따른다.
4. 기술 스택은 특별한 지시가 없으면 정적 웹 + 최소 백엔드(Next.js 또는 Node/Express)로 단순하게.
5. 명세에 없는 정보(백엔드 로직·CMS·결제 연동)는 추측하지 말고 구현 전에 질문하라.
## 1. 내 요구사항 (변형 지시)
${requirements}
## 2. 디자인 토큰 (측정 원본)
${section('컬러', [
line('팔레트 (사용 빈도순)', (colors.palette || []).slice(0, 8).map((p: any) => `${p.value}(×${p.count})`)),
line('배경', colors.background),
line('본문 텍스트', colors.primaryText),
line('링크', colors.linkColor),
line('버튼 배경', colors.buttonBackground),
line('버튼 텍스트', colors.buttonText),
line('구성 비율', colors.composition?.ratioLabel),
line('다크모드 신호', colors.darkModeHints),
])}
${section('타이포그래피', [
line('주 폰트', typo.primaryFont),
line('폰트 스택', (typo.fontStack || []).slice(0, 3)),
line('h1', typo.h1),
line('h2', typo.h2),
line('h3', typo.h3),
line('본문', typo.body),
line('버튼', typo.button),
line('상위 크기', (typo.topFontSizes || []).slice(0, 6)),
line('상위 굵기', (typo.topFontWeights || []).slice(0, 5)),
])}
${section('레이아웃 & 여백', [
line('본문 최대 폭', layout.bodyMaxWidth),
line('섹션 간 여백', layout.sectionSpacing),
line('카드 간격', layout.cardSpacing),
line('radius 스케일', layout.borderRadiusScale),
line('그리드', layout.grids),
line('컨테이너', layout.containerSystem),
line('반응형 신호', layout.responsiveHints),
])}
${section('인터랙션', [
line('hover 규칙', (inter.hoverRules || []).slice(0, 6)),
line('focus 규칙', (inter.focusRules || []).slice(0, 3)),
line('transition 분포', inter.transitionDistribution),
line('CSS 변수', inter.cssVars),
])}
## 3. 마이크로카피 원문 (톤앤매너의 근거 — 변형 시에도 이 공식·리듬 유지)
${[
line('헤드라인', micro.headline),
line('서브헤드라인', micro.subheadline || (micro.subheadlines || []).slice(0, 4)),
line('CTA 샘플', (micro.ctaSamples || []).slice(0, 8)),
line('placeholder', micro.placeholders),
line('상태 메시지', micro.stateMessages),
line('본문 샘플', typeof micro.bodySample === 'string' ? micro.bodySample.slice(0, 300) : micro.bodySample),
].filter(Boolean).join('\n') || '- (스캔 데이터 없음)'}
## 4. 페이지 인벤토리 (IA)
\`\`\`
${String(s.sitemap?.ascii || '(단일 페이지)').trimEnd()}
\`\`\`
| URL | 역할 | 제목/H1 | 콘텐츠 유형 | 이미지 수 | 폼 필드 | CTA |
|---|---|---|---|---|---|---|
${pageRows || '| (단일 페이지 스캔) | - | - | - | - | - | - |'}
## 5. 원시 스캔 데이터 부록 (기계 판독용 — 위 요약에 없는 세부값은 여기서 인용)
\`\`\`json
${j({
meta: s.meta,
design: s.design,
interactions: s.interactions,
microcopy: s.microcopy,
structure: s.structure,
}).slice(0, 60_000)}
\`\`\`
`;
}
@@ -0,0 +1,97 @@
/**
* 레이아웃 골격 리포트 — 스캔 데이터에서 **결정론으로** 페이지별 위→아래 구성을 그린다.
*
* 배경 (v2.2.307): /benchmark 결과가 "색·폰트" 수준에 머물고, LLM 합성이 죽으면
* 레이아웃 정보가 아예 사라졌다 (실사례: koritips 스캔 — 파트 2 타임아웃 → 원시
* 요약만 남음). 골격(어떤 섹션이 어떤 순서로, 배너/캐러셀/폼 위젯이 어디 있는지)은
* 스캔의 sectionRoles(태그 + class/id 힌트 + 미리보기)에서 LLM 없이 뽑을 수 있다.
* → 이 모듈이 항상 보고서에 포함되므로 합성이 실패해도 골격 분석은 남는다.
*/
/** class/id 힌트 → 위젯 종류 추정 (단일 권위 — 새 신호는 여기만 추가). */
export const WIDGET_SIGNALS: ReadonlyArray<{ re: RegExp; label: string }> = [
{ re: /carousel|swiper|slick|slider|slideshow|glide|splide/i, label: '🎠 캐러셀/롤링 배너' },
{ re: /hero|jumbotron|masthead|banner|billboard/i, label: '🖼️ 히어로/배너' },
{ re: /nav|menu|gnb|header/i, label: '🧭 내비게이션/헤더' },
{ re: /footer/i, label: '🦶 푸터' },
{ re: /sidebar|aside|widget-area/i, label: '📌 사이드바' },
{ re: /search/i, label: '🔍 검색' },
{ re: /subscribe|newsletter|cta|signup/i, label: '📮 구독/CTA' },
{ re: /grid|card|list|archive|loop|posts/i, label: '🗂️ 카드/리스트 그리드' },
{ re: /comment/i, label: '💬 댓글' },
{ re: /related|recommend/i, label: '🔗 관련 글' },
{ re: /breadcrumb/i, label: '🧵 브레드크럼' },
{ re: /pagination|paging|pager/i, label: '📄 페이지네이션' },
];
export function classifyWidget(hint: string): string | null {
const h = (hint || '').trim();
if (!h) return null;
for (const { re, label } of WIDGET_SIGNALS) if (re.test(h)) return label;
return null;
}
function sectionLine(sec: any, idx: number): string {
const tag = sec.tag || sec.role || 'section';
const hint = String(sec.hint || '').slice(0, 40);
const widget = classifyWidget(hint);
const preview = String(sec.preview || sec.text || sec.textPreview || '').replace(/\s+/g, ' ').slice(0, 60);
const counts = [
sec.btns != null || sec.buttonCount != null ? `버튼 ${sec.btns ?? sec.buttonCount}` : '',
sec.imgs != null || sec.imgCount != null ? `이미지 ${sec.imgs ?? sec.imgCount}` : '',
sec.links != null || sec.linkCount != null ? `링크 ${sec.links ?? sec.linkCount}` : '',
].filter(Boolean).join(' · ');
return [
`${idx + 1}. \`<${tag}>\``,
widget ? widget : '',
hint ? `\`${hint}\`` : '',
preview ? `— "${preview}"` : '',
counts ? `(${counts})` : '',
].filter(Boolean).join(' ');
}
/**
* 페이지별 위→아래 골격 마크다운. 스캔이 비어도 깨지지 않는다.
* LLM 합성 성공/실패와 무관하게 보고서에 항상 포함할 것.
*/
export function buildLayoutSkeleton(scan: any): string {
const s = scan || {};
const out: string[] = ['## 레이아웃 골격 (스캔 실측 — LLM 미개입)', ''];
// 홈(루트) — structure.sections 가 가장 상세.
const rootSections: any[] = s.structure?.sections || [];
if (rootSections.length) {
out.push(`### 홈 \`/\` — 위→아래 블록 구성`);
rootSections.slice(0, 12).forEach((sec, i) => out.push(`- ${sectionLine(sec, i)}`));
out.push('');
}
// 하위 페이지 — sitemap.pages[].sectionRoles.
const pages: any[] = (s.sitemap?.pages || []).filter((p: any) => !p?.error);
for (const p of pages) {
const roles: any[] = p.sectionRoles || [];
const pathLabel = (() => { try { return new URL(p.url, 'https://x').pathname; } catch { return p.url || '?'; } })();
if (pathLabel === '/' && rootSections.length) continue; // 홈은 위에서 상세 처리
if (!roles.length) continue;
out.push(`### \`${decodeURIComponent(pathLabel)}\` (${p.role || '?'} · ${p.primaryContentType || '?'})`);
roles.slice(0, 10).forEach((sec, i) => out.push(`- ${sectionLine(sec, i)}`));
out.push('');
}
// 감지된 위젯 총괄 — "롤링 배너가 있다/없다"에 바로 답하는 요약.
const allHints: string[] = [
...rootSections.map((x: any) => String(x.hint || '')),
...pages.flatMap((p: any) => (p.sectionRoles || []).map((x: any) => String(x.hint || ''))),
];
const found = new Set<string>();
for (const h of allHints) { const w = classifyWidget(h); if (w) found.add(w); }
const carousel = [...found].some(w => w.includes('캐러셀'));
out.push('### 위젯 감지 요약');
out.push(`- 감지됨: ${found.size ? [...found].join(', ') : '(없음)'}`);
out.push(`- 롤링 배너/캐러셀: ${carousel ? '**있음** (class/id 신호 기준)' : '신호 없음 — 스캔 힌트에 캐러셀 계열 class 가 없었음 (JS 렌더 위젯일 수 있어 실사이트 확인 권장)'}`);
if (!rootSections.length && !pages.some((p: any) => (p.sectionRoles || []).length)) {
return '## 레이아웃 골격 (스캔 실측)\n- 섹션 데이터가 스캔에 없습니다 (depth/pages 를 올려 재스캔 권장).';
}
return out.join('\n');
}
@@ -1,5 +1,12 @@
/** /benchmark 보고서의 3 파트 분할 — 1: 4-렌즈 / 2: IA + 토큰 / 3: 재구축 명세. */
export type SynthesisPart = 1 | 2 | 3;
/**
* /benchmark 보고서의 파트 분할.
* 1: 4-렌즈 디자인 분석 / 2: IA + 디자인 토큰 / 3: 원본 재구축 명세 (재해석 금지)
* 4: 비즈니스 벤치마킹 렌즈 (v2.2.302 — 포지셔닝·가격·수익구조·운영 방향성.
* 피터 레벨스 류 1인 AI 기업 분석 프레임 이식. 여기서만 사용자 컨텍스트로 변형 제안 허용)
* 5: 단계별 빌드 프롬프트 (v2.2.302 — 재구축 명세를 바이브코딩 IDE 에 바로
* 붙여넣는 스텝별 프롬프트 교재로 변환)
*/
export type SynthesisPart = 1 | 2 | 3 | 4 | 5;
/**
* scan JSON → 4-렌즈 분석 LLM 프롬프트. Datacollect 웹앱(WebBenchmarkPanel)의
@@ -149,19 +156,24 @@ ${commonHeader}
### 1-2. 타이포그래피 (Typography)
## 2. 레이아웃 및 여백 (Layout & Whitespace)
### 2-1. 그리드 시스템 (Grid System)
### 2-2. 섹션 간 여백 (Section Spacing)
### 2-3. 카드/카드 그리드 (Card Spacing)
### 2-4. Border Radius / 컨테이너
### 2-1. 페이지 골격 서술 (Page Skeleton — 위→아래)
- \`structure.sections\` 와 \`sitemap.pages[].sectionRoles\` 를 근거로, 홈과 대표 페이지의 블록 구성을 **위→아래 순서 문장**으로 서술하라 (예: "헤더/GNB → 히어로 배너 → 최신 글 카드 그리드(이미지 40장) → 구독 폼 → 푸터").
- 각 섹션의 \`hint\`(class/id) 신호로 위젯을 판별하라: carousel/swiper/slick/slider = 롤링 배너, hero/banner = 정적 배너, grid/card/list = 카드 그리드 등. **배너가 롤링(캐러셀)인지 정적인지 반드시 판정**하고, 신호가 없으면 "캐러셀 class 신호 없음 — 정적으로 추정"이라고 명시하라 (지어내기 금지).
### 2-2. 그리드 시스템 (Grid System)
### 2-3. 섹션 간 여백 (Section Spacing)
### 2-4. 카드/카드 그리드 (Card Spacing)
### 2-5. Border Radius / 컨테이너
## 3. 마이크로 인터랙션 (Micro Interaction)
### 3-1. Hover / Focus 효과
### 3-2. Transition 패턴
### 3-3. 레이어링 (z-index / position)
## 4. 라이팅 톤앤매너 (Microcopy & Voice)
### 4-1. 헤드라인 / 서브헤드라인 / CTA 카피
### 4-2. Placeholder 및 보이스 신호`
## 4. 톤앤매너 (Tone & Manner)
### 4-1. 시각적 톤앤매너 한 문단 (Visual Personality)
- 컬러 비율·폰트·radius·여백 수치를 종합해 이 사이트의 시각적 성격을 한 문단으로 규정하라 (예: "무채색 80% + 하늘색 포인트, 넉넉한 여백, 산세리프 — 정보 전달 중심의 차분한 블로그 톤"). 수치를 인용하되 성격 규정은 문장으로.
### 4-2. 헤드라인 / 서브헤드라인 / CTA 카피
### 4-3. Placeholder 및 보이스 신호`
: part === 2
? `
## 5. 정보 구조 / 사이트 맵 (Information Architecture)
@@ -199,7 +211,7 @@ ${commonHeader}
- **재사용 컴포넌트**: 5-5에서 정의한 컴포넌트 이름을 콤마로 나열.
표 아래에 각 템플릿을 ATag/CSS 명세 수준으로 풀어 쓰는 짧은 단락을 덧붙여도 좋다 (선택).`
: `
: part === 3 ? `
## 9. 원본 사이트 재구축 명세 (Rebuild Spec — Same Site, Built From Scratch)
> **⚠️ 이 단계의 미션 (절대 이탈 금지)**
@@ -230,13 +242,70 @@ ${commonHeader}
- 스캔으로는 잡히지 않는 영역(다이나믹 데이터·CMS 구조·실제 폰트 라이선스·결제 연동 등)을 나열. 추측이 필요한 부분만 적고, 임의로 결정하지 말 것.
> **주의**: 이 단계는 새로운 서비스 기획이 아니라 **원본 사이트 그 자체를 다시 짓기 위한 시방서**다. 9-1 ~ 9-6의 모든 값은 part 1~8에서 인용한 수치여야 한다.`;
> **주의**: 이 단계는 새로운 서비스 기획이 아니라 **원본 사이트 그 자체를 다시 짓기 위한 시방서**다. 9-1 ~ 9-6의 모든 값은 part 1~8에서 인용한 수치여야 한다.`
: part === 4 ? `
## 10. 서비스 정의 & 포지셔닝 (What Are They Selling)
### 10-1. 한 줄 포지셔닝 분석
- 헤드라인(\`microcopy.headline\`)이 어떤 공식인지 분석하라 (예: "Fire your photographer" = <대체 대상> + <도발적 동사> 공식). 카피 원문을 인용할 것.
### 10-2. 타깃 사용자와 핵심 가치 제안
- CTA·서브헤드라인·폼 필드에서 역추적한 타깃 고객과, 이 서비스가 대체하는 기존 비용(사람/시간/돈).
### 10-3. 서비스 유형 분류
- 생성형 AI 도구 / SaaS / 콘텐츠 / 커머스 중 무엇이며, 핵심 엔진(이미지 생성·텍스트 생성 등)은 무엇으로 추정되는지. 근거 문구 인용.
## 11. 가격 & 수익 구조 (Pricing & Unit Economics)
### 11-1. 가격 티어 추출
- 스캔 데이터(h2List·ctaSamples·페이지 텍스트)에서 발견한 가격·크레딧·구독 문구를 **원문 그대로** 표로 정리. 가격 정보가 스캔에 없으면 "스캔 데이터 부족 — \`depth=2\` 로 pricing 페이지 포함 재스캔 권장"이라고 쓰고 이 섹션을 추정으로 채우지 마라.
### 11-2. AI 원가 추정 (표)
- 이 서비스의 핵심 생성 1회당 원가를 시나리오 표로: (a) 상용 API 사용 시 (일반적으로 알려진 단가 범위 — 반드시 "추정"으로 표기), (b) 오픈소스 모델 셀프호스팅 시 (GPU 서버 고정비 방식). 두 방식의 손익분기 조건을 "사용자 N명 × 월 생성 M회" 형태의 부등식으로 제시.
### 11-3. 마진 시뮬레이션
- 판매가 − AI 원가 − 결제 수수료(~3.3%) 산술을 표로. 스캔에서 가격을 못 찾았으면 가상의 판매가라고 명시하고 계산 구조만 보여라.
## 12. 기능 인벤토리 & 사용자 여정 (Feature Inventory)
### 12-1. 사용자 여정 지도
- 폼 필드·CTA·페이지 흐름에서 역추적: 방문 → (예: 업로드 → 스타일 선택 → 생성 → 결과 → 결제/구독) 단계별로. 각 단계의 근거 데이터를 인용.
### 12-2. 백엔드 요구 기능
- 인증 / 결제(어떤 PG 흔적이 있는지) / 생성 큐 / 스토리지 / 크레딧 차감 등 프론트 뒤에 있어야 할 기능 목록.
### 12-3. MVP 컷 (2~3일 버전)
- "빠르게 만들어 시장 반응을 본다" 원칙으로, 위 기능 중 첫 배포에 꼭 필요한 것만 남긴 최소 범위. 뺄 것을 명시하는 것이 핵심.
## 13. 운영 & 방향성 제안 (Operations & Direction)
> 이 섹션에서만 [사용자 보조 컨텍스트]를 적극 결합해 변형·확장을 제안하라 (part 3의 원본 복원 원칙과 분리된 목적).
### 13-1. 시리즈화 아이디어
- 이 서비스의 공식(포지셔닝+엔진)을 유지한 채 겉만 재포장할 수 있는 인접 서비스 3개 (예: "Fire your X" 시리즈).
### 13-2. 광고비 0원 마케팅 채널
- 이 유형의 서비스가 초기 사용자를 모으는 현실적 채널 2~3개와 각 채널에 맞는 첫 게시물 아이디어.
### 13-3. 출시 후 검증 지표
- 시장 반응을 판단할 최소 지표 (방문→업로드 전환율, 생성→결제 전환율 등)와 "접을 기준"까지.
### 13-4. 사용자 컨텍스트 결합 변형
- [사용자 보조 컨텍스트]가 있으면 그 방향으로 이 모델을 변형한 구체안 1개. 없으면 "(컨텍스트 미입력)" 한 줄만.`
: `
## 14. 단계별 빌드 프롬프트 (Step-by-Step Build Prompts)
> **미션**: 아래 스텝 프롬프트들을 바이브코딩 IDE 에 순서대로 붙여넣기만 하면 원본과 매우 유사한 서비스가 완성되어야 한다.
> - 각 스텝은 \`### STEP N: <제목>\` 헤딩 + 프롬프트 전문을 담은 코드블록 1개.
> - 프롬프트 안에 스캔에서 추출한 **실제 디자인 토큰 값**(색상 rgb/hex·폰트·radius·spacing)과 헤드라인·CTA 원문을 그대로 박아 넣어라 — "적절한 색"·"모던한 폰트" 같은 모호어 금지.
> - 각 프롬프트는 자기완결적: 이전 스텝의 산출물을 전제하되, 참조하는 값은 다시 명시한다.
> - 기술 스택은 특별한 근거가 없으면 정적 웹 + 최소 백엔드(Node/Express 또는 Next.js) 로 단순하게.
### STEP 1: 랜딩 페이지 (헤드라인·히어로·가격 섹션 — 디자인 토큰 포함)
### STEP 2: 핵심 도구 UI (업로드/입력 → 옵션 선택 → 결과 표시 골격, API 미연결 상태)
### STEP 3: 생성 API 연동 (.env 키 보호, 로딩·에러 상태, 결과 다운로드)
### STEP 4: 결제/크레딧 최소 구현 (또는 대기자 명단 폼 — MVP 컷 기준)
각 STEP 아래에 "확인 방법" 한 줄(브라우저에서 무엇이 보이면 성공인지)을 덧붙여라.
## 15. 빌드 순서 요약 (Build Order)
- STEP 1→4 를 표로: | 스텝 | 산출물 | 소요 추정 | 확인 방법 |.`;
const partGoal = part === 1
? '1/3단계: 원본 사이트의 시각·인터랙션·카피 톤을 4-렌즈로 분석한다.'
? '이번 단계: 원본 사이트의 시각·인터랙션·카피 톤을 4-렌즈로 분석한다.'
: part === 2
? '2/3단계: 원본의 IA, 페이지 템플릿, 디자인 토큰, 준비 리소스를 정리한다. 8단계 Page Template Map은 반드시 표 형식으로 작성한다.'
: '3/3단계: 원본 레퍼런스 사이트를 처음부터 다시 만들기 위한 개발 명세를 작성한다. **사용자 컨텍스트는 무시하고, 다른 서비스로 재해석하지 않는다.**';
? '이번 단계: 원본의 IA, 페이지 템플릿, 디자인 토큰, 준비 리소스를 정리한다. 8단계 Page Template Map은 반드시 표 형식으로 작성한다.'
: part === 3
? '이번 단계: 원본 레퍼런스 사이트를 처음부터 다시 만들기 위한 개발 명세를 작성한다. **사용자 컨텍스트는 무시하고, 다른 서비스로 재해석하지 않는다.**'
: part === 4
? '이번 단계: 이 사이트를 하나의 *사업*으로 분석한다 — 무엇을 팔고, 어떻게 돈을 벌고, 내가 비슷한 서비스를 만든다면 어떻게 운영할지. 스캔 데이터에 있는 문구·가격·CTA 를 근거로 하되, 스캔에 없는 가격·매출은 지어내지 말고 "스캔 데이터 부족"으로 표기한다.'
: '이번 단계: 앞선 분석을 바탕으로, 바이브코딩 IDE(Antigravity 등)에 순서대로 붙여넣기만 하면 이 사이트와 매우 유사한 서비스가 만들어지는 **스텝별 빌드 프롬프트**를 작성한다. 각 프롬프트는 자기완결적이어야 한다 (앞 스텝 결과를 전제하되 필요한 값은 다시 명시).';
return `당신은 시니어 UX/UI 분석가 겸 프론트엔드 아키텍트다.
${sharedRules}
Binary file not shown.
+3 -14
View File
@@ -18,6 +18,7 @@
* 수동 명령과 완전 호환.
*/
import * as fs from 'fs';
import { nowInKst, parseHhMm } from '../../lib/kstSchedule';
import * as path from 'path';
import * as vscode from 'vscode';
import { getConfig } from '../../config';
@@ -47,23 +48,11 @@ let _timer: NodeJS.Timeout | undefined;
let _disposed = false;
let _lastFiredYmd = '';
function nowInKst(): { hour: number; minute: number; ymd: string; weekday: number } {
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')}`;
return { hour: Number(get('hour')), minute: Number(get('minute')), ymd, weekday: new Date(`${ymd}T00:00:00Z`).getUTCDay() };
}
function cycleSchedule(): { day: number; hour: number; minute: number } {
const cfg = vscode.workspace.getConfiguration('g1nation');
const day = Math.min(6, Math.max(0, cfg.get<number>('growthCycle.day', 0)));
const raw = (cfg.get<string>('growthCycle.time', '20:00') || '20:00').trim();
const m = raw.match(/^(\d{1,2}):(\d{2})$/);
return { day, hour: m ? Math.min(23, Number(m[1])) : 20, minute: m ? Math.min(59, Number(m[2])) : 0 };
const t = parseHhMm(cfg.get<string>('growthCycle.time', '20:00'), 20, 0);
return { day, hour: t.hour, minute: t.minute };
}
function msUntilNextFire(): number {
+2 -12
View File
@@ -18,6 +18,7 @@
* - 스케줄: 매일 KST 03:00 (기본) — stocksWatcher 와 동일한 setTimeout 체인 패턴.
*/
import * as fs from 'fs';
import { nowInKst, parseHhMm } from '../../lib/kstSchedule';
import * as path from 'path';
import * as vscode from 'vscode';
import { getConfig } from '../../config';
@@ -213,19 +214,8 @@ export async function runSleepDigestOnce(): Promise<string> {
// ── 일일 스케줄러 (KST, stocksWatcher 패턴) ──────────────────────────────────
function nowInKst(): { hour: number; minute: number; ymd: string } {
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';
return { hour: Number(get('hour')), minute: Number(get('minute')), ymd: `${get('year')}-${get('month')}-${get('day')}` };
}
function digestTime(): { hour: number; minute: number } {
const raw = (vscode.workspace.getConfiguration('g1nation').get<string>('sleepDigest.time', '03:00') || '03:00').trim();
const m = raw.match(/^(\d{1,2}):(\d{2})$/);
return m ? { hour: Math.min(23, Number(m[1])), minute: Math.min(59, Number(m[2])) } : { hour: 3, minute: 0 };
return parseHhMm(vscode.workspace.getConfiguration('g1nation').get<string>('sleepDigest.time', '03:00'), 3, 0);
}
function msUntilNextFire(): number {
+205
View File
@@ -0,0 +1,205 @@
/**
* Claude Code CLI adapter — **구독(Pro/Max) 인증으로 Claude 를 Astra 엔진으로**.
*
* 배경 (v2.2.305): Anthropic 구독은 API 키가 아니라 Claude Code 로그인 방식이라
* `anthropic:` 프로바이더(API 키·종량제)로는 쓸 수 없다. 이 어댑터는 사용자가
* 이미 로그인해 둔 로컬 `claude` CLI 를 headless(-p) 로 실행해 추론만 위임한다 —
* 추가 과금 없이 구독 한도 안에서 동작.
*
* 통합 방식: 다른 클라우드 프로바이더와 동일하게 *OpenAI 호환 SSE* Response 를
* 반환한다 → agent.ts 의 기존 SSE 파서가 그대로 소비 (모델 드롭다운에
* "Claude 구독 · sonnet" 으로 노출, 선택만 하면 됨).
*
* 설계 결정:
* - 프롬프트는 argv 가 아니라 **stdin** 으로 전달 — Astra 의 시스템 프롬프트+
* 컨텍스트는 수십 KB 라 ARG_MAX(맥 256KB) 위험이 있다.
* - `--output-format stream-json` 을 파싱하되, 버전 간 이벤트 차이를 피하려고
* turn 단위(assistant 메시지)로 수집해 SSE 로 흘린다 (부분 델타 플래그는
* 구버전 CLI 에 없어 사용하지 않음).
* - 실패는 명확한 에러로 — CLI 미설치/미로그인 시 설치·로그인 안내를 에러
* 본문에 담는다 (조용한 폴백 금지).
*/
import { spawn } from 'child_process';
import * as fs from 'fs';
import * as os from 'os';
import * as vscode from 'vscode';
import { StreamParams, ChatMessage } from './types';
import { execFileCapture } from '../../lib/execUtil';
import { logInfo, logWarn } from '../../utils';
/** 드롭다운에 노출할 구독 모델 별칭 — CLI 의 --model 이 그대로 받는 값. */
export function listClaudeCodeModels(): string[] {
return ['sonnet', 'opus', 'haiku'];
}
// ── CLI 경로 탐지 (프로세스 생존 동안 캐시) ─────────────────────────────────
let _cliPath: string | null | undefined;
function candidatePaths(): string[] {
const cfg = vscode.workspace.getConfiguration('g1nation').get<string>('claude.cliPath', '')?.trim();
const home = os.homedir();
const cands = [
...(cfg ? [cfg] : []),
'claude',
'/usr/local/bin/claude',
'/opt/homebrew/bin/claude',
`${home}/.claude/local/claude`,
`${home}/.npm-global/bin/claude`,
...(process.platform === 'win32' ? ['claude.cmd', 'claude.exe'] : []),
];
return [...new Set(cands)];
}
/** 로그인된 Claude Code CLI 실행 파일을 찾는다. null = 미설치 (설치 안내는 호출자). */
export async function resolveClaudeCli(): Promise<string | null> {
if (_cliPath !== undefined) return _cliPath;
for (const cand of candidatePaths()) {
// 절대 경로 후보는 존재 확인으로 빠르게 거르고, PATH 후보는 --version 실행으로 검증.
if (cand.includes('/') || cand.includes('\\')) {
try { if (!fs.existsSync(cand)) continue; } catch { continue; }
}
const r = await execFileCapture(cand, ['--version'], { timeoutMs: 8_000 });
if (r.code === 0) {
_cliPath = cand;
logInfo('[ClaudeCode] CLI 확인.', { path: cand, version: r.stdout.trim().slice(0, 40) });
return cand;
}
}
_cliPath = null;
return null;
}
/** 테스트/설정 변경 시 캐시 초기화. */
export function _resetClaudeCliCache(): void { _cliPath = undefined; }
// ── 프롬프트 조립 (순수 — 테스트 대상) ──────────────────────────────────────
/**
* Astra 의 messages 배열을 claude -p 가 받는 단일 프롬프트로 평탄화.
* 시스템 프롬프트는 명시적 블록으로, 대화 이력은 화자 라벨로 보존한다.
*/
export function flattenClaudePrompt(messages: ChatMessage[]): string {
const sys: string[] = [];
const turns: string[] = [];
for (const m of messages) {
if (m.role === 'system') sys.push(m.content);
else turns.push(`${m.role === 'user' ? '[사용자]' : '[아스트라]'}\n${m.content}`);
}
const parts: string[] = [];
if (sys.length) {
parts.push(`<system_instructions>\n${sys.join('\n\n')}\n</system_instructions>`);
parts.push('위 system_instructions 를 너의 시스템 지침으로 따르라. 도구를 사용하지 말고, 마지막 [사용자] 메시지에 대한 답변 본문만 출력하라.');
}
if (turns.length) parts.push(turns.join('\n\n'));
return parts.join('\n\n');
}
/** CLI 인자 (순수 — 테스트 대상). 프롬프트는 stdin 으로 가므로 여기 없음. */
export function buildClaudeCliArgs(model: string): string[] {
const args = ['-p', '--output-format', 'stream-json', '--verbose'];
const m = (model || '').trim();
if (m) args.push('--model', m);
return args;
}
/** stream-json 라인들에서 답변 텍스트/에러 추출 (순수 — 테스트 대상). */
export function extractFromStreamJson(lines: string[]): { text: string; error: string } {
let text = '';
let error = '';
for (const line of lines) {
const t = line.trim();
if (!t.startsWith('{')) continue;
let j: any;
try { j = JSON.parse(t); } catch { continue; }
if (j.type === 'assistant' && Array.isArray(j.message?.content)) {
for (const block of j.message.content) {
if (block?.type === 'text' && typeof block.text === 'string') text += block.text;
}
} else if (j.type === 'result') {
if (j.is_error || (j.subtype && j.subtype !== 'success')) {
error = String(j.result || j.error || j.subtype || 'unknown error');
} else if (!text && typeof j.result === 'string') {
text = j.result; // 일부 버전은 assistant 이벤트 없이 result 에만 본문
}
}
}
return { text, error };
}
// ── 실행기 ──────────────────────────────────────────────────────────────────
const LOGIN_HINT = 'Claude Code CLI 확인이 필요합니다 — 터미널에서 `claude` 실행 후 구독 계정으로 로그인하세요. 미설치라면 `npm install -g @anthropic-ai/claude-code`. (경로가 특이하면 설정 g1nation.claude.cliPath 지정)';
export interface ClaudeCodeResult { text: string }
/** claude -p 1회 실행 — 성공 시 답변 텍스트, 실패 시 throw(안내 포함 메시지). */
export async function runClaudeCode(params: {
messages: ChatMessage[];
model: string;
signal?: AbortSignal;
timeoutMs?: number;
}): Promise<ClaudeCodeResult> {
const cli = await resolveClaudeCli();
if (!cli) throw new Error(LOGIN_HINT);
const prompt = flattenClaudePrompt(params.messages);
const args = buildClaudeCliArgs(params.model);
const cwd = vscode.workspace.workspaceFolders?.find(f => f.uri.scheme === 'file')?.uri.fsPath || os.homedir();
const timeoutMs = params.timeoutMs ?? 300_000; // 클라우드 대형 모델 — 여유 있게
return new Promise<ClaudeCodeResult>((resolve, reject) => {
const child = spawn(cli, args, { cwd, windowsHide: true });
let out = '';
let err = '';
let settled = false;
const finish = (fn: () => void) => { if (!settled) { settled = true; clearTimeout(timer); fn(); } };
const timer = setTimeout(() => {
try { child.kill(); } catch { /* already dead */ }
finish(() => reject(new Error(`Claude 응답이 ${Math.round(timeoutMs / 1000)}초 안에 오지 않아 중단했습니다.`)));
}, timeoutMs);
if (params.signal) {
const onAbort = () => { try { child.kill(); } catch { /* noop */ } finish(() => reject(new Error('AbortError'))); };
if (params.signal.aborted) onAbort();
else params.signal.addEventListener('abort', onAbort, { once: true });
}
child.stdout.on('data', (d) => { out += String(d); });
child.stderr.on('data', (d) => { err += String(d); });
child.on('error', (e) => finish(() => reject(new Error(`Claude CLI 실행 실패(${e.message}). ${LOGIN_HINT}`))));
child.on('close', (code) => finish(() => {
const { text, error } = extractFromStreamJson(out.split('\n'));
if (text.trim()) return resolve({ text: text.trim() });
const detail = (error || err || `exit ${code}`).trim().split('\n').slice(-4).join(' ').slice(0, 400);
logWarn('[ClaudeCode] 빈 응답/실패.', { code, detail });
reject(new Error(`Claude 응답 실패: ${detail}\n${LOGIN_HINT}`));
}));
child.stdin.write(prompt);
child.stdin.end();
});
}
// ── 스트리밍 어댑터 (OpenAI 호환 SSE Response) ─────────────────────────────
export async function streamClaudeCode(
_context: vscode.ExtensionContext,
params: StreamParams,
): Promise<Response> {
try {
const { text } = await runClaudeCode({
messages: params.messages,
model: params.model,
signal: params.signal,
});
const sse = [
`data: ${JSON.stringify({ choices: [{ delta: { content: text }, finish_reason: null }] })}`,
'',
`data: ${JSON.stringify({ choices: [{ delta: {}, finish_reason: 'stop' }] })}`,
'',
'data: [DONE]',
'', '',
].join('\n');
return new Response(sse, { status: 200, headers: { 'Content-Type': 'text/event-stream' } });
} catch (e: any) {
if (e?.message === 'AbortError') throw e;
return new Response(JSON.stringify({ error: { message: e?.message || String(e) } }), {
status: 500, headers: { 'Content-Type': 'application/json' },
});
}
}
+9
View File
@@ -12,6 +12,7 @@ import { ProviderId, StreamParams, ChatMessage, parseModelPrefix, makeModelId, p
import { streamOpenRouter, listOpenRouterModels } from './openrouter';
import { streamAnthropic, listAnthropicModels } from './anthropic';
import { streamGemini, listGeminiModels } from './gemini';
import { streamClaudeCode, listClaudeCodeModels, resolveClaudeCli } from './claudeCode';
import { readProviderConfig, writeProviderConfig, readProviderStatus, ProviderConfig } from './providerConfig';
export type { ProviderId, ChatMessage, StreamParams, ProviderConfig };
@@ -31,6 +32,7 @@ export async function streamCloudCompletion(
case 'openrouter': return streamOpenRouter(context, fullParams);
case 'anthropic': return streamAnthropic(context, fullParams);
case 'gemini': return streamGemini(context, fullParams);
case 'claude-code': return streamClaudeCode(context, fullParams);
}
}
@@ -64,6 +66,13 @@ export async function listAllCloudModels(context: vscode.ExtensionContext): Prom
for (const id of ids) out.push({ id: makeModelId('gemini', id), provider: 'gemini', label: `Gemini · ${id}` });
}));
}
// Claude 구독 — API 키 불필요: 로그인된 Claude Code CLI 가 있으면 곧 활성.
tasks.push(resolveClaudeCli().then((cli) => {
if (!cli) return;
for (const id of listClaudeCodeModels()) {
out.push({ id: makeModelId('claude-code', id), provider: 'claude-code', label: `Claude 구독 · ${id}` });
}
}).catch(() => { /* 탐지 실패 = 미노출 */ }));
await Promise.all(tasks);
return out;
}
+9 -3
View File
@@ -13,7 +13,7 @@
* - 옛 사용자 model 설정 ('gemma4:e2b') 은 prefix 없으니 자동으로 local 경로.
*/
export type ProviderId = 'openrouter' | 'anthropic' | 'gemini';
export type ProviderId = 'openrouter' | 'anthropic' | 'gemini' | 'claude-code';
export interface ChatMessage {
role: 'system' | 'user' | 'assistant';
@@ -32,6 +32,8 @@ const PROVIDER_PREFIXES: ReadonlyArray<{ prefix: string; id: ProviderId }> = [
{ prefix: 'openrouter:', id: 'openrouter' },
{ prefix: 'anthropic:', id: 'anthropic' },
{ prefix: 'gemini:', id: 'gemini' },
// Claude 구독(Pro/Max) — API 키가 아니라 로컬 Claude Code CLI 로그인에 위임 (v2.2.305).
{ prefix: 'claude:', id: 'claude-code' },
];
/**
@@ -48,14 +50,18 @@ export function parseModelPrefix(modelId: string): { provider: ProviderId; model
return null;
}
/** 역변환 — provider + 실제 model → prefixed id. UI / config 저장용. */
/** 역변환 — provider + 실제 model → prefixed id. UI / config 저장용.
* 주의: prefix 는 PROVIDER_PREFIXES 가 단일 권위 — provider id 와 다를 수 있다
* (claude-code 의 prefix 는 사용자 친화적으로 'claude:'). */
export function makeModelId(provider: ProviderId, model: string): string {
return `${provider}:${model}`;
const hit = PROVIDER_PREFIXES.find(p => p.id === provider);
return `${hit ? hit.prefix : provider + ':'}${model}`;
}
/** UI 표시용 짧은 라벨. */
export function providerLabel(p: ProviderId): string {
switch (p) {
case 'claude-code': return 'Claude 구독';
case 'openrouter': return 'OpenRouter';
case 'anthropic': return 'Anthropic';
case 'gemini': return 'Gemini';
@@ -21,7 +21,8 @@
* - 워크스페이스 외부 경로 무시
* - 사용자가 `executionVerification=false`면 통째로 skip — 호출자가 가드
*/
import { spawn, spawnSync } from 'child_process';
import { spawn } from 'child_process';
import { detectPythonSync, PYTHON_CANDIDATES } from '../../lib/execUtil';
import * as path from 'path';
import * as fs from 'fs';
import { logError, logInfo } from '../../utils';
@@ -86,23 +87,10 @@ function _firstNonEmptyLine(s: string): string {
* Returns the platform-preferred default when neither is found so the caller
* still produces the usual "미설치" warning via the spawn-failure path.
*/
let _pythonCmdCache: string | undefined;
// [코어 수렴] Python 탐지는 lib/execUtil 의 단일 구현·공유 캐시를 쓴다.
// 미설치 시에도 플랫폼 기본 후보를 돌려줘 spawn-실패 경로가 기존처럼 "미설치" 경고를 만든다.
function _resolvePythonCmd(): string {
if (_pythonCmdCache !== undefined) return _pythonCmdCache;
const candidates = process.platform === 'win32'
? ['python', 'python3']
: ['python3', 'python'];
for (const cmd of candidates) {
try {
const r = spawnSync(cmd, ['--version'], { stdio: 'ignore', windowsHide: true });
if (!r.error && (r.status === 0 || r.status === null)) {
_pythonCmdCache = cmd;
return cmd;
}
} catch { /* try next candidate */ }
}
_pythonCmdCache = candidates[0];
return _pythonCmdCache;
return detectPythonSync() ?? PYTHON_CANDIDATES[0];
}
/** 확장자별 검사 명령 결정. 지원 안 하는 확장자면 null 반환 (skip). */
+4 -3
View File
@@ -13,6 +13,7 @@
* - 시스템 Python 자체 설치는 OS 정책상 자동화 위험 → python.org 링크 안내만.
*/
import * as vscode from 'vscode';
import { PYTHON_CANDIDATES } from '../../lib/execUtil';
import { spawn } from 'child_process';
import { logInfo, logError } from '../../utils';
@@ -44,9 +45,9 @@ export async function probePythonEnv(): Promise<PythonProbe> {
};
// Windows 는 `python` / `py` 가 보통 우선, 그 외엔 `python3` 가 안전.
const candidates = process.platform === 'win32'
? ['python', 'py', 'python3']
: ['python3', 'python'];
// [코어 수렴] 후보 순서는 lib/execUtil 과 공유 (이 probe 는 Python3 버전·패키지 검증까지
// 수행하는 특수 경로라 탐지 로직 자체는 유지한다).
const candidates = PYTHON_CANDIDATES;
for (const cmd of candidates) {
const ver = await _capture(cmd, ['--version'], 5_000);
+1 -17
View File
@@ -1,4 +1,5 @@
import * as vscode from 'vscode';
import { nowInKst } from '../../lib/kstSchedule';
import { logError, logInfo } from '../../utils';
import { readStocksStore, writeStocksStore } from './stocksStore';
import { fetchAllPrices } from './yahooClient';
@@ -32,23 +33,6 @@ function scheduleTimesKst(): { hour: number; minute: number }[] {
let _timer: NodeJS.Timeout | undefined;
let _disposed = false;
/** Asia/Seoul 기준 *지금* 의 hour/minute. */
function nowInKst(): { date: Date; hour: number; minute: number; ymd: string } {
const now = new Date();
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(now);
const get = (t: string) => Number(parts.find(p => p.type === t)?.value || '0');
return {
date: now,
hour: get('hour'),
minute: get('minute'),
ymd: `${get('year')}-${parts.find(p => p.type === 'month')?.value}-${parts.find(p => p.type === 'day')?.value}`,
};
}
/**
* 지금부터 다음 firing 까지 milliseconds 계산.
* SCHEDULE_HOURS_KST 중 *오늘 남은 시각* 우선, 다 지났으면 내일 첫 시각.
+13 -30
View File
@@ -1,3 +1,4 @@
import { coreChat } from '../core/services';
/**
* 단순 non-streaming LLM 호출 — Ollama / LM Studio(OpenAI 호환) 듀얼 엔드포인트.
*
@@ -19,34 +20,16 @@ export async function simpleChatCompletion(
user: string,
options: SimpleChatOptions,
): Promise<string> {
const isOllama = options.baseUrl.includes(':11434') || options.baseUrl.includes('ollama');
const endpoint = isOllama ? `${options.baseUrl}/api/chat` : `${options.baseUrl}/v1/chat/completions`;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), options.timeoutMs ?? 120000);
try {
const messages = [
{ role: 'system', content: system },
{ role: 'user', content: user },
];
const body = isOllama
? { model: options.model, stream: false, messages, options: { temperature: options.temperature ?? 0.2, num_predict: options.maxTokens ?? 1200 } }
: { model: options.model, stream: false, temperature: options.temperature ?? 0.2, max_tokens: options.maxTokens ?? 1200, messages };
const res = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
signal: controller.signal,
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data: any = await res.json();
return String(
data?.message?.content ??
data?.choices?.[0]?.message?.content ??
data?.choices?.[0]?.text ??
data?.response ??
'',
);
} finally {
clearTimeout(timer);
}
// [코어 수렴] 자체 fetch·엔진 휴리스틱 제거 — core/services.coreChat 으로 통일.
// options.baseUrl 은 하위 호환용으로 남지만 무시된다 (코어가 설정의 ollamaUrl 사용 —
// 기존 호출자들도 전부 config.ollamaUrl 을 넘기고 있었으므로 동작 동일).
const result = await coreChat({
system,
user,
model: options.model,
timeoutMs: options.timeoutMs ?? 120000,
temperature: options.temperature ?? 0.2,
maxTokens: options.maxTokens ?? 1200,
});
return result.content;
}
@@ -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])) };
}
+5 -15
View File
@@ -16,22 +16,12 @@ const DEFAULT_BUDGET: ContextBudgetConfig = {
maxChunks: 12
};
/**
* 토큰 수를 대략 추정합니다 (문자 수 / 4).
* 한국어는 글자당 토큰이 더 많으므로 보정합니다.
*/
export function estimateTokens(text: string): number {
// 한국어 비율 추정
const koreanChars = (text.match(/[가-힣]/g) || []).length;
const totalChars = text.length;
const koreanRatio = totalChars > 0 ? koreanChars / totalChars : 0;
// [코어 수렴] 토큰 추정은 lib/contextManager 의 단일 구현을 쓴다 (CJK 전반 커버·
// 보수적 계수). 예전 이 파일의 자체 추정(한글만 1.5/0.25)은 제거 — 기존 소비자
// 호환을 위해 같은 이름으로 재수출.
import { estimateTokens } from '../lib/contextManager';
export { estimateTokens };
// 한국어는 글자당 ~1.5 토큰, 영어는 ~0.25 토큰
const koreanTokens = koreanChars * 1.5;
const otherTokens = (totalChars - koreanChars) * 0.25;
return Math.ceil(koreanTokens + otherTokens);
}
/**
* 검색 결과 청크들을 토큰 예산 내에서 선택합니다.
+6 -1
View File
@@ -1,6 +1,7 @@
import * as fs from 'fs';
import * as path from 'path';
import { getConfig } from '../config';
import { logInfo } from '../utils';
/**
* 단계별 지식 스코프 라우터 — 소형 모델 정밀 검색의 핵심.
@@ -147,8 +148,12 @@ export function resolveDomainScope(query: string, brainRoot: string): DomainScop
try { if (fs.existsSync(abs) && fs.statSync(abs).isDirectory()) valid.push(abs); } catch { /* skip */ }
}
// Specialty(마지막 항목)가 살아남지 못했으면 스코프 포기 — General 만으로는 좁히지 않는다.
// [조용한 폴백 금지] 설정은 돼 있는데 폴더가 없으면(예: 지식 재편 후 옛 경로) 로그로 드러낸다.
const specialtyAbs = path.resolve(rootAbs, relFolders[relFolders.length - 1]);
if (!valid.includes(specialtyAbs)) return none;
if (!valid.includes(specialtyAbs)) {
logInfo('domain-knowledge: Specialty 폴더가 존재하지 않아 전체 두뇌로 폴백합니다. 설정 경로를 확인하세요.', { domain, folder: relFolders[relFolders.length - 1] });
return none;
}
return {
domain,
+36
View File
@@ -228,6 +228,42 @@ export class RetrievalOrchestrator {
* 으로 반환한다. recall@k / MRR 계산용. 프로덕션 `retrieve()` 와 동일한 scoring 경로
* (`searchBrainFiles`) 를 그대로 재사용하므로, 측정값이 실제 검색 동작을 반영한다 (무결성).
*/
/**
* [코어 수렴] 스코프 지정 두뇌 검색 — 기업 모드(dispatcher) 등 "메모리 계층 없이
* 두뇌만" 필요한 소비자용 공개 경로. 프로덕션 retrieve() 와 동일한 searchBrainFiles
* (임베딩 하이브리드·actionability·청크 검색 포함)를 태우므로, 경량 scopedBrainRetriever
* 와 달리 오케스트레이터의 검색 개선을 그대로 받는다.
*/
public retrieveBrainChunksScoped(
query: string,
brain: BrainProfile,
opts: {
limit?: number;
scopeFolders?: string[];
includeRawConversations?: boolean;
queryEmbedding?: number[];
embeddingModel?: string;
embeddingBlendAlpha?: number;
chunkLevelRetrieval?: boolean;
chunkTargetChars?: number;
} = {},
): RetrievalChunk[] {
const expandedTokens = expandQuery(tokenize(query));
return this.searchBrainFiles(
query,
expandedTokens,
brain,
opts.limit ?? 6,
opts.includeRawConversations ?? false,
opts.scopeFolders ?? [],
opts.queryEmbedding,
opts.embeddingModel,
opts.embeddingBlendAlpha,
opts.chunkLevelRetrieval || false,
opts.chunkTargetChars ?? 1200,
);
}
public rankBrainForEval(
query: string,
brain: BrainProfile,
+10 -40
View File
@@ -18,6 +18,7 @@
*/
import { RetrievalChunk } from './types';
import { coreChat } from '../core/services';
export interface SemanticRerankOptions {
ollamaUrl: string;
@@ -94,46 +95,15 @@ async function callLlmForRerank(
user: string,
timeoutMs: number,
): Promise<string> {
const isOllama = ollamaUrl.includes(':11434') || ollamaUrl.includes('ollama');
const endpoint = isOllama ? `${ollamaUrl}/api/chat` : `${ollamaUrl}/v1/chat/completions`;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const body = isOllama
? {
model, stream: false,
messages: [
{ role: 'system', content: system },
{ role: 'user', content: user },
],
options: { temperature: 0.0, num_predict: 256 },
}
: {
model,
messages: [
{ role: 'system', content: system },
{ role: 'user', content: user },
],
stream: false, temperature: 0.0, max_tokens: 256,
};
const res = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
signal: controller.signal,
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data: any = await res.json();
const content =
data?.message?.content ??
data?.choices?.[0]?.message?.content ??
data?.choices?.[0]?.text ??
data?.response ??
'';
return String(content || '');
} finally {
clearTimeout(timer);
}
// [코어 수렴] 자체 fetch·엔진 휴리스틱 제거 — coreChat 으로 통일 (온도 0, 256토큰).
const result = await coreChat({
system, user, model,
timeoutMs,
temperature: 0.0,
maxTokens: 256,
});
return String(result.content || '');
}
/** LLM 응답에서 ranking 배열 추출 + 검증. 실패 시 null. */
@@ -23,6 +23,8 @@ import { getConfig } from '../../config';
export interface DispatcherDepsInputs {
context: vscode.ExtensionContext;
ai: AIService;
/** [코어 수렴] 오케스트레이터 기반 두뇌 검색 thunk (AgentExecutor.retrieveBrainBlockForCompany). */
retrieveBrainContext?: (query: string, scopeFolders: string[], limit: number) => Promise<string>;
signal: AbortSignal;
onEvent: (event: CompanyTurnEvent) => void;
/** Action tag (`<create_file>` 등) 를 실제 파일시스템에 적용. AgentExecutor 의 thunk. */
@@ -44,6 +46,7 @@ export function buildDispatcherDeps(inputs: DispatcherDepsInputs): DispatcherDep
return {
context: inputs.context,
ai: inputs.ai,
retrieveBrainContext: inputs.retrieveBrainContext,
defaultModel: cfg.defaultModel || 'gemma4:e2b',
// Knowledge Mix wiring — 회사 specialists 도 사용자의 Second Brain 을 같은
// global 기본값 + per-agent override semantics 로 사용. 없으면 Knowledge Mix
+2
View File
@@ -1868,6 +1868,8 @@ export class SidebarChatProvider implements vscode.WebviewViewProvider, BridgeIn
const deps = buildDispatcherDeps({
context: this._context,
ai,
// [코어 수렴] 기업 모드 검색을 메인 오케스트레이터 경로로 승격.
retrieveBrainContext: (q, folders, limit) => this._agent.retrieveBrainBlockForCompany(q, folders, limit),
signal: abort.signal,
onEvent: emitter.emit,
executeActionTags: (text: string) => this._agent.executeActionTagsOnText(text),
+3
View File
@@ -1,4 +1,5 @@
import * as vscode from 'vscode';
import { buildToneGuardBlock } from './lib/contextBuilders/toneGuard';
import * as path from 'path';
import * as os from 'os';
import * as fs from 'fs';
@@ -282,6 +283,8 @@ R7. GUESS-AND-ACT WITH STATED ASSUMPTION. When information is missing but a reas
사용자가 명시적으로 화제를 닫지 않는 한 "다음에 무엇을 도와드릴까요?" 류 마무리 금지.
${buildToneGuardBlock()}
[ENGINEERING STANCE]
- Be a direct engineering partner. Technical precision over polite filler.
- Collapse checklists into: verdict → reason → risk → next move. (R1 already requires the verdict to be sentence 1.)