feat: v2.2.92 → v2.2.158 — god-file 분해 + Stocks feature + 대화 연속성
R56–R59: agent.ts 2731→1529줄 god-file 분해 (25 modules) · attrParsers + LLM 메서드 8개 (callNonStreaming, streamChatOnce 등) · executeActions 415줄 → 8 handler 그룹 (file/run/list/brain/calendar/sheets/tasks) · handlePrompt 1100줄 → 7 phase 모듈 (system prompt + budget + autoContinue 등) R50–R55: extension.ts 1145→349줄 (telegram/settings/provider commands 분리) Stocks feature 신규: /stocks slash command (v2.2.152~158) · .astra/stocks.json 저장소 + Yahoo Finance 현재가 갱신 · 8 키워드 필터 (ROE/성장성/유동성/수익성/영업효율/기술력/안정성/PBR) · Naver 시가총액 페이지 JSON API (m.stock.naver.com) 발굴 · LLM Top 5 매력도 분석 + Telegram 자동 보고서 · KST 09:00/15:00 watcher 자동 모니터링 대화 연속성 (v2.2.150~157): · [PRIOR TURN CONCLUSION] block 으로 직전 결론 anchor · thin follow-up 분류 → boilerplate 헤더 suppression · slash 명령 결과 chatHistory mirror (capture wrapper) · echo/parrot 금지 system prompt rule 기타: /stocks 슬래시 자동완성 dropdown UI, Naver JSON API 전환 (cheerio 제거) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Astra Mode Architecture Context Builder.
|
||||
*
|
||||
* 의도: 사용자가 *Astra 자체의 mode 디자인* (Guard vs Multi-Agent 가 별도 모드여야
|
||||
* 하는지) 을 묻는 메타 질문에 답할 때, 모델이 일반론적 답이 아니라 *현재 코드베이스
|
||||
* 의 실제 구조* 를 보고 답하게 하려고 시스템 프롬프트에 추가 컨텍스트 블록을 주입.
|
||||
*
|
||||
* 이 builder 는 *stateless* — instance state 의존 없고 외부 호출 없음. agent.ts 의
|
||||
* private 메서드로 박혀 있던 걸 별도 파일로 extract 해서 god file 무게 줄임 +
|
||||
* 향후 단위 테스트 용이성 확보.
|
||||
*/
|
||||
|
||||
/**
|
||||
* 사용자 prompt 가 "Astra 의 Guard vs MA 모드 분리 결정" 류 메타 질문인지 판정.
|
||||
*
|
||||
* 3가지 신호가 *모두* 있어야 true — 너무 광범위하게 잡으면 평범한 질문에도
|
||||
* 잘못된 컨텍스트가 박혀서 모델이 헷갈림.
|
||||
*/
|
||||
export function isAstraModeArchitectureQuestion(prompt: string): boolean {
|
||||
if (!prompt) return false;
|
||||
const mentionsGuard = /\bguard\b|가드|Guard|Chronicle Guard|Project Chronicle/i.test(prompt);
|
||||
const mentionsMultiAgent = /\bMA\b|multi[-\s]?agent|멀티\s*에이전트|다중\s*에이전트|Planner|Researcher|Writer/i.test(prompt);
|
||||
const asksDecision = /(분리|통합|모드|사용|좋을까|맞을까|구조|설계|아키텍처|의견|판단|어때|어떤\s*거?\s*같|separate|combine|mode|architecture|design|opinion)/i.test(prompt);
|
||||
return asksDecision && mentionsGuard && mentionsMultiAgent;
|
||||
}
|
||||
|
||||
/**
|
||||
* 메타 질문 감지 시 시스템 프롬프트에 prepend 할 컨텍스트 블록.
|
||||
* 감지 안 되면 빈 문자열 (호출자가 그대로 조립 가능).
|
||||
*/
|
||||
export function buildAstraModeArchitectureContext(prompt: string): string {
|
||||
if (!isAstraModeArchitectureQuestion(prompt)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return [
|
||||
'[ASTRA MODE ARCHITECTURE DECISION CONTEXT]',
|
||||
'The user is asking about Astra itself, specifically whether Guard mode and MA/Multi-Agent mode should remain separate.',
|
||||
'',
|
||||
'Confirmed implementation facts from the current codebase:',
|
||||
'- Guard is currently exposed as a sidebar toggle, but it defaults to enabled in the webview UI.',
|
||||
'- Guard context is built by buildProjectChronicleGuardContext(activeProject) and passed into AgentExecutor as designerContext.',
|
||||
'- In the normal single-agent path, designerContext is injected into the system prompt as [PROJECT CHRONICLE GUARD].',
|
||||
'- In the Multi-Agent path, designerContext is appended as Project Chronicle Guard context for the workflow manager.',
|
||||
'- Multi-Agent is an internal execution strategy. The legacy g1nation.multiAgentEnabled setting can still force it for complex prompts, but Astra may also select it automatically for report/research/strategy style tasks.',
|
||||
'- Current guardrail: Multi-Agent is not used for local project path preflight or Astra mode-design questions, because those need richer context assembly first.',
|
||||
'',
|
||||
'Product decision guidance:',
|
||||
'- Do not treat Guard and MA as two equal user-facing modes.',
|
||||
'- Guard should be an always-on policy/context layer: project target, evidence discipline, record hygiene, tone, and decision logging.',
|
||||
'- MA should be an optional execution strategy chosen automatically for genuinely complex tasks.',
|
||||
'- Recommended UX: hide or de-emphasize the Guard toggle, show it as Auto/On by default, and let Astra route between single-agent and MA internally.',
|
||||
'- Recommended answer: give a clear verdict that separating them as peer modes is not ideal; separate them internally by responsibility instead.',
|
||||
'- Mention the concrete risk that MA can currently bypass richer context assembly, so unifying the context preparation before routing is the next engineering step.',
|
||||
].join('\n');
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { ChatMessage } from '../../agent';
|
||||
|
||||
/**
|
||||
* v2.2.69 — sliding-window 가 잘라낸 메시지들을 한 줄 요약으로 압축.
|
||||
*
|
||||
* 추가 LLM 호출 없이 heuristic 으로:
|
||||
* - 사용자 prompt 첫 문장
|
||||
* - assistant 답변 첫 문장 (conclusion-first 가정 — R1)
|
||||
* 만 추출해 시간순으로 이어붙인다. 모델이 "이전에 무슨 얘기를 했는지" 의
|
||||
* 골자만 알면 충분하다.
|
||||
*
|
||||
* 너무 많으면 가장 오래된 절반은 한 줄로 합치고 최근 8개만 보존 — 요약 자체가
|
||||
* 컨텍스트를 다시 차지하면 본말전도.
|
||||
*
|
||||
* Stateless — agent.ts 의 private 메서드를 그대로 추출. instance state 의존
|
||||
* 없고 외부 호출 없음. 테스트 용이성 + god-file 무게 줄이기 위해 분리.
|
||||
*/
|
||||
export function buildDroppedHistorySummary(dropped: ChatMessage[]): string {
|
||||
if (dropped.length === 0) return '';
|
||||
const lines: string[] = [];
|
||||
let userTurnIdx = 0;
|
||||
for (const msg of dropped) {
|
||||
if (msg.internal) continue;
|
||||
const content = typeof msg.content === 'string' ? msg.content : '';
|
||||
if (!content.trim()) continue;
|
||||
if (msg.role === 'user') {
|
||||
userTurnIdx++;
|
||||
lines.push(`U${userTurnIdx}: ${firstSentence(content)}`);
|
||||
} else if (msg.role === 'assistant') {
|
||||
lines.push(`A${userTurnIdx}: ${firstSentence(content)}`);
|
||||
}
|
||||
}
|
||||
const MAX_LINES = 8;
|
||||
if (lines.length > MAX_LINES) {
|
||||
const tail = lines.slice(-MAX_LINES);
|
||||
const head = lines.slice(0, lines.length - MAX_LINES);
|
||||
return `[이전 대화 요약 — 총 ${dropped.length}개 메시지가 컨텍스트 한계로 생략됨]\n(더 오래된 ${head.length}개 턴 생략됨)\n${tail.join('\n')}`;
|
||||
}
|
||||
return `[이전 대화 요약 — 총 ${dropped.length}개 메시지가 컨텍스트 한계로 생략됨]\n${lines.join('\n')}`;
|
||||
}
|
||||
|
||||
function firstSentence(s: string): string {
|
||||
const cleaned = String(s || '')
|
||||
.replace(/^\s{0,3}#{1,6}\s+/gm, '')
|
||||
.replace(/\*\*/g, '')
|
||||
.replace(/`{3}[\s\S]*?`{3}/g, '[code]')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
const m = cleaned.match(/^[^.!?。\n]{1,140}[.!?。]?/);
|
||||
const out = (m ? m[0] : cleaned.slice(0, 140)).trim();
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { ChatMessage } from '../../agent';
|
||||
|
||||
/**
|
||||
* LLM 엔진 호출 직전 메시지 배열 정규화 + 엔진별 variant 생성.
|
||||
*
|
||||
* - normalizeMessages — content 가 객체면 stringify (Ollama Vision 의 images
|
||||
* 필드는 보존). 엔진 API 가 항상 plain object array 만 받기 때문.
|
||||
* - buildEngineMessageVariants — LM Studio 한정으로 system role 이 가끔 무시되는
|
||||
* 버그 우회용 fallback variant 도 함께 만들어 호출자가 차례로 시도하게 한다
|
||||
* (native-system 실패 → flattened-system-fallback). Ollama 는 그냥 native 1개.
|
||||
*
|
||||
* 둘 다 stateless — agent.ts 의 private 메서드를 그대로 추출.
|
||||
*/
|
||||
|
||||
export function normalizeMessages(messages: ChatMessage[]) {
|
||||
return messages.map((message) => {
|
||||
const normalizedContent = typeof message.content === 'string'
|
||||
? message.content
|
||||
: JSON.stringify(message.content);
|
||||
|
||||
const result: any = {
|
||||
role: message.role,
|
||||
content: normalizedContent,
|
||||
};
|
||||
// Ollama Vision: images 필드 보존
|
||||
if ((message as any).images) {
|
||||
result.images = (message as any).images;
|
||||
}
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
export function buildEngineMessageVariants(messages: ChatMessage[], engine: 'lmstudio' | 'ollama') {
|
||||
const normalized = normalizeMessages(messages);
|
||||
if (engine !== 'lmstudio') {
|
||||
return [{ name: 'native', messages: normalized }];
|
||||
}
|
||||
|
||||
// LM Studio system-role bug 우회: system 메시지를 "[System Instruction - do not
|
||||
// answer this message]\n…" 로 감싸 user role 로 변환. 호출자는 native 가 실패하면
|
||||
// 이 flattened variant 로 재시도한다.
|
||||
const flattened = normalized.map((message) => {
|
||||
if (message.role === 'system') {
|
||||
return {
|
||||
role: 'user' as const,
|
||||
content: `[System Instruction - do not answer this message]\n${message.content}`,
|
||||
};
|
||||
}
|
||||
return message;
|
||||
});
|
||||
|
||||
return [
|
||||
{ name: 'native-system', messages: normalized },
|
||||
{ name: 'flattened-system-fallback', messages: flattened },
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { ChatMessage } from '../../agent';
|
||||
|
||||
/**
|
||||
* 모델에게 보낼 직전 history 변환 모음. 두 stage:
|
||||
* 1) sanitizeHistoryAssistantContent — 옛 답변에 박혀 있던 *디버그/내부* 섹션
|
||||
* (Second Brain Trace, candidate records 등) 을 제거. 모델이 자기 옛 답변에서
|
||||
* 쓸데없는 메타 정보를 재학습해 다음 답변에 또 박는 회귀 방지.
|
||||
* 2) buildRequestHistory — 그걸 모든 assistant 메시지에 적용해 새 배열 반환.
|
||||
*
|
||||
* 둘 다 stateless — agent.ts 의 private 메서드를 그대로 추출. 새 sanitize 규칙
|
||||
* 추가 시 god-file 변경 없이 이 모듈만 수정.
|
||||
*/
|
||||
export function buildRequestHistory(history: ChatMessage[]): ChatMessage[] {
|
||||
return history.map((message) => {
|
||||
if (message.role !== 'assistant' || typeof message.content !== 'string') {
|
||||
return message;
|
||||
}
|
||||
return {
|
||||
...message,
|
||||
content: sanitizeHistoryAssistantContent(message.content),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* In-memory chat history 가 무한 성장하지 않도록 두 단계로 정리:
|
||||
* 1) `recentFullMessages` 이전의 *내부 tool-result* 메시지 (read_file /
|
||||
* list_files / list_brain / read_brain) 본문을 `oldToolResultCap` 자로
|
||||
* truncate — 최근 메시지는 그대로 둬서 진행 중 흐름은 영향 없음.
|
||||
* 2) 전체 길이가 `maxRetained` 초과면 가장 오래된 메시지부터 drop. 단 선두가
|
||||
* system 메시지면 그것만 보존 (세션 복원/대화 framing 깨짐 방지).
|
||||
*
|
||||
* 매개변수에 cap 값을 다 받아 stateless — agent.ts 의 옛 private 메서드가 class
|
||||
* static 상수를 직접 읽던 걸 인수로 옮김.
|
||||
*
|
||||
* 주의: history 를 *in-place mutate* 한다 (splice + content 직접 수정). 호출자는
|
||||
* 같은 배열을 그대로 사용하면 됨. 새 배열을 만들지 않으므로 reference 가 그대로
|
||||
* 유지돼야 하는 케이스에 안전.
|
||||
*/
|
||||
export function capChatHistory(
|
||||
history: ChatMessage[],
|
||||
opts: { maxRetained: number; recentFullMessages: number; oldToolResultCap: number },
|
||||
): void {
|
||||
if (history.length === 0) return;
|
||||
|
||||
const recentStart = Math.max(0, history.length - opts.recentFullMessages);
|
||||
for (let i = 0; i < recentStart; i++) {
|
||||
const msg = history[i];
|
||||
if (msg.role !== 'system' || !msg.internal || typeof msg.content !== 'string') continue;
|
||||
if (!/^\[Result of (read_file|list_files|list_brain|read_brain)\b/.test(msg.content)) continue;
|
||||
if (msg.content.length <= opts.oldToolResultCap) continue;
|
||||
msg.content = msg.content.slice(0, opts.oldToolResultCap)
|
||||
+ '\n…[이전 도구 결과는 컨텍스트 절약을 위해 축약되었습니다]';
|
||||
}
|
||||
|
||||
if (history.length > opts.maxRetained) {
|
||||
const first = history[0];
|
||||
const preserveFirst = first.role === 'system';
|
||||
const overflow = history.length - opts.maxRetained;
|
||||
if (preserveFirst) {
|
||||
history.splice(1, overflow);
|
||||
} else {
|
||||
history.splice(0, overflow);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function sanitizeHistoryAssistantContent(content: string): string {
|
||||
return content
|
||||
.replace(/<details>\s*<summary>2nd Brain Trace:[\s\S]*?<\/details>/gi, '')
|
||||
.replace(/## Second Brain Debug JSON[\s\S]*?(?=\n## |\n# |$)/gi, '')
|
||||
.replace(/## Candidate records for this discussion[\s\S]*?(?=\n## |\n# |$)/gi, '')
|
||||
.replace(/## 후보 기록[\s\S]*?(?=\n## |\n# |$)/gi, '')
|
||||
.replace(/## 프로젝트 기록 검토[\s\S]*?(?=\n## |\n# |$)/gi, '')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim();
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { isThinkingPartnerRequest } from './promptDetection';
|
||||
import { extractEvidenceFilesFromProjectKnowledge, extractPriorityPreviewFiles } from './projectEvidence';
|
||||
import { buildThinkingPartnerResponseContract } from './thinkingPartnerContract';
|
||||
|
||||
/**
|
||||
* "자비스 프로젝트 브리프" — 사용자가 thinking-partner 톤의 메타 질문을 던지면
|
||||
* 모델에게 *어떤 프로젝트* 의 *어떤 증거 파일* 을 보고 답해야 하는지 알려주는
|
||||
* 시스템 prompt prepend 블록.
|
||||
*
|
||||
* 우선순위: 직전 localPathContext (실제 path access 성공) > project knowledge
|
||||
* record. 둘 다 없으면 "성급한 단정 금지" 안내 + thinking-partner contract 만
|
||||
* 붙여 반환.
|
||||
*
|
||||
* Stateless — agent.ts 의 private 메서드를 그대로 추출. 의존: thinking-partner
|
||||
* detection + evidence file 추출 두 헬퍼 (역시 이미 추출됨) + response contract.
|
||||
*/
|
||||
export function buildJarvisProjectBriefContext(
|
||||
prompt: string,
|
||||
localPathContext: string,
|
||||
recentProjectKnowledgeContext: string,
|
||||
): string {
|
||||
if (!isThinkingPartnerRequest(prompt)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const sourceContext = localPathContext && localPathContext.includes('Access: succeeded')
|
||||
? localPathContext
|
||||
: recentProjectKnowledgeContext;
|
||||
if (!sourceContext) {
|
||||
return [
|
||||
'[JARVIS PROJECT BRIEF]',
|
||||
'No concrete local project brief is available yet.',
|
||||
'Use the conversation and Second Brain cautiously. If the user asks about a project architecture, ask for or inspect the project path before making strong claims.',
|
||||
'',
|
||||
buildThinkingPartnerResponseContract(),
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
const projectPath = sourceContext.match(/Path:\s*(.+)/)?.[1]?.trim()
|
||||
|| sourceContext.match(/Repository:\s*`([^`]+)`/)?.[1]?.trim()
|
||||
|| sourceContext.match(/project evidence:\s*([^\s]+)/i)?.[1]?.trim()
|
||||
|| 'current project';
|
||||
const evidenceFiles = sourceContext.includes('Priority file previews:')
|
||||
? extractPriorityPreviewFiles(sourceContext).slice(0, 10)
|
||||
: extractEvidenceFilesFromProjectKnowledge(sourceContext).slice(0, 10);
|
||||
const treeMatch = sourceContext.match(/Scanned tree:\n([\s\S]*?)(?:\nPriority file previews:|$)/);
|
||||
const treePreview = treeMatch?.[1]?.trim().split('\n').slice(0, 30).join('\n') || '';
|
||||
|
||||
return [
|
||||
'[JARVIS PROJECT BRIEF]',
|
||||
`Project evidence target: ${projectPath}`,
|
||||
evidenceFiles.length
|
||||
? `Evidence files available:\n${evidenceFiles.map((file) => `- ${file}`).join('\n')}`
|
||||
: 'Evidence files available: not enough concrete file markers were found.',
|
||||
treePreview ? `Visible structure preview:\n${treePreview}` : '',
|
||||
'',
|
||||
buildThinkingPartnerResponseContract(),
|
||||
].filter(Boolean).join('\n');
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { ChatMessage } from '../../agent';
|
||||
|
||||
/**
|
||||
* v2.2.69 — chatHistory 의 마지막 user 턴에서 사용자가 무슨 주제를 다루고
|
||||
* 있었는지 한 줄로 뽑아 모드 전환 bridge 의 "이전 맥락" 문장에 쓴다.
|
||||
*
|
||||
* 비어 있으면 빈 문자열. 너무 길면 120자 cap (bridge 문장은 짧아야 의미 있음).
|
||||
*
|
||||
* agent.ts 의 private 메서드를 추출 — 옛 버전은 `this.chatHistory` 를 직접 읽어
|
||||
* stateful 했지만, history 를 명시 arg 로 받게 만들어 stateless 화. 호출자가
|
||||
* 이미 자기 history 를 들고 있으니 의존 방향이 자연스럽다.
|
||||
*/
|
||||
export function buildLastTopicLine(history: ChatMessage[]): string {
|
||||
const recent = history.filter(m => !m.internal && (m.role === 'user' || m.role === 'assistant'));
|
||||
if (recent.length === 0) return '';
|
||||
const lastUser = [...recent].reverse().find(m => m.role === 'user');
|
||||
if (!lastUser || typeof lastUser.content !== 'string') return '';
|
||||
return lastUser.content.replace(/\s+/g, ' ').trim().slice(0, 120);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { getConfig } from '../../config';
|
||||
import type { LmStudioSampling } from '../../lmstudio/streamer';
|
||||
|
||||
/**
|
||||
* LM Studio 호출 직전에 config 에서 sampling 파라미터들을 모아 한 객체로 변환.
|
||||
* SDK streamer 의 `respond()` (topPSampling/topKSampling/…) 와 REST body
|
||||
* (top_p/top_k/…) 둘 다 같은 모양에서 변환되므로, 두 path 가 동일 답변을 내도록
|
||||
* 한 곳에 집중. Ollama 도 같은 필드명을 `options` 안에서 받아들임.
|
||||
*
|
||||
* 옛 코드: agent.ts 의 두 private 메서드 — 5 + 7 호출처. 둘 다 stateless config
|
||||
* 읽기라 extract 하면 단위 테스트 + config shape 변경 시 한 곳 수정.
|
||||
*/
|
||||
|
||||
/** SDK / REST 양쪽이 공통으로 쓰는 sampling block. */
|
||||
export function lmStudioSamplingFromConfig(): LmStudioSampling {
|
||||
const c = getConfig();
|
||||
return {
|
||||
topP: c.lmStudioTopP,
|
||||
topK: c.lmStudioTopK,
|
||||
minP: c.lmStudioMinP,
|
||||
repeatPenalty: c.lmStudioRepeatPenalty,
|
||||
};
|
||||
}
|
||||
|
||||
/** SDK `respond()` 전용 extras — 현재는 speculative decoding 의 draft model 뿐. */
|
||||
export function lmStudioRespondExtrasFromConfig(): { draftModel?: string } {
|
||||
const c = getConfig();
|
||||
return c.lmStudioDraftModel ? { draftModel: c.lmStudioDraftModel } : {};
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
import { isThinkingPartnerRequest } from './promptDetection';
|
||||
|
||||
/**
|
||||
* 로컬 프로젝트 *의도 분류* + *tone-shaping* 의 한 묶음. 옛 코드는 agent.ts 의
|
||||
* 6개 private 메서드 + 2개 static regex 로 흩어져 있었음.
|
||||
*
|
||||
* 의존 그래프:
|
||||
* containsLocalFilePath ──┐
|
||||
* ├─→ classifyLocalProjectIntent ─→ {isProjectKnowledge..,
|
||||
* │ isProjectReview..,
|
||||
* │ buildAstraStanceContext}
|
||||
* shouldPreflightLocalProjectPath ─┘
|
||||
*
|
||||
* buildLocalProjectIntentGuidance — 독립, intent 만 받음
|
||||
*
|
||||
* 모두 stateless 정규식 기반이라 god-file 무게 빼기 + 단위 테스트 노출 + 다른
|
||||
* 모드에서 재사용을 위해 한 모듈로 격리.
|
||||
*
|
||||
* 주의 — 정규식 lastIndex pollution 방지:
|
||||
* /g/ flag 없이 만든 RegExp 인스턴스는 `.test()` 반복 호출에 안전. /g/ 를
|
||||
* 붙이면 lastIndex 가 호출간 누적돼 두 번째 .test() 가 false 가 되는 버그가
|
||||
* 날 수 있으니 새 패턴 추가 시 주의.
|
||||
*/
|
||||
|
||||
export type LocalProjectIntent =
|
||||
| 'review-evaluation'
|
||||
| 'knowledge-creation'
|
||||
| 'implementation'
|
||||
| 'documentation'
|
||||
| 'thinking'
|
||||
| 'general';
|
||||
|
||||
// POSIX: /Volumes/, /Users/, /home/, /opt/, ... or ~/ — backtick 제외 (markdown code spans).
|
||||
// Source string 도 export — 호출자가 /g/ flag 가 필요한 matchAll 패턴에 새 RegExp 인스턴스를
|
||||
// 만들 수 있어야 함 (lastIndex pollution 방지 위해 인스턴스 공유 안 함).
|
||||
export const POSIX_ABS_PATH_SRC = "(?:\\/(?:Volumes|Users|home|opt|srv|mnt|data|workspace)\\/|~\\/)[^\\s`\"'<>|*?]+";
|
||||
// Windows: drive letter (C:\ or C:/) or UNC (\\server\share). Backslash 가 separator 로 허용됨.
|
||||
export const WIN_ABS_PATH_SRC = "(?:[A-Za-z]:[\\\\/]|\\\\\\\\[^\\s\\\\/]+\\\\[^\\s\\\\/]+)[^\\s`\"'<>|*?]*";
|
||||
|
||||
export const ABS_PATH_RE = new RegExp(POSIX_ABS_PATH_SRC, 'i');
|
||||
export const WIN_ABS_PATH_RE = new RegExp(WIN_ABS_PATH_SRC, 'i');
|
||||
|
||||
/**
|
||||
* Prompt 에 로컬 파일/디렉토리 경로가 포함돼 있는지. 절대 경로 (POSIX + Windows
|
||||
* + UNC) 또는 흔한 상대 경로 패턴 (`src/...`, `lib/...` + 확장자) 둘 다 잡는다.
|
||||
*/
|
||||
export function containsLocalFilePath(prompt: string): boolean {
|
||||
if (ABS_PATH_RE.test(prompt) || WIN_ABS_PATH_RE.test(prompt)) {
|
||||
return true;
|
||||
}
|
||||
if (/(?:^|[\s,])(?:src|lib|components|pages|app|tests|test|utils|core|features|hooks|services|config|public|assets|docs|scripts)[\\/]/i.test(prompt)
|
||||
&& /\.[a-z]{1,6}(?:[\s,;)\]]|$)/i.test(prompt)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 사용자 prompt 가 로컬 경로 + 어떤 *작업 동사* 둘 다 포함하는지. 둘 다 있어야
|
||||
* preflight (실제 디스크 scan + 컨텍스트 주입) 가 의미 있음. 작업 동사 없이
|
||||
* 경로만 있으면 사용자가 단순히 경로를 언급한 것일 수 있어 scan skip.
|
||||
*/
|
||||
export function shouldPreflightLocalProjectPath(prompt: string): boolean {
|
||||
const hasActionKeyword = /(검토|리뷰|분석|확인|봐줘|읽어|열어|파일|내용|코드|고쳐|개선|디버그|지식|문서화|문서|정리|기록|위키|저장|만들|생성|설계|아키텍처|구조|방향|의견|생각|판단|어떤\s*거?\s*같|어때|순서대로|보면|knowledge|document|documentation|wiki|summari[sz]e|review|analy[sz]e|inspect|debug|fix|improve|architecture|design|structure|opinion|think|judge|read|open|file|content|code)/i.test(prompt);
|
||||
const hasLocalPath = containsLocalFilePath(prompt);
|
||||
return hasActionKeyword && hasLocalPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Intent 분류기 — preflight 결과에 따라 어떤 tone / 어떤 가이던스 블록을 prompt 에
|
||||
* 박을지 결정. 경로가 안 보이면 'general' 로 단락 — 분류 자체가 의미 없음.
|
||||
*
|
||||
* 분류 우선순위: review > implementation > knowledge-creation > documentation
|
||||
* > thinking > general. 이 순서가 *겹치는 키워드* 가 있을 때 어느 쪽으로 단정할지를
|
||||
* 결정하므로 함부로 바꾸지 말 것.
|
||||
*/
|
||||
export function classifyLocalProjectIntent(prompt: string): LocalProjectIntent {
|
||||
if (!containsLocalFilePath(prompt)) {
|
||||
return 'general';
|
||||
}
|
||||
|
||||
const normalized = prompt.replace(/\s+/g, ' ').trim();
|
||||
const asksReview = /(코드\s*리뷰|코드리뷰|리뷰|검토|평가|봐줘|장점|단점|약점|강점|확장성|문제점|리스크|개선점|의견|판단|괜찮|어때|어떤\s*거?\s*같|review|evaluate|assessment|strength|weakness|pros?\s*and\s*cons?|extensibility|scalability|risk|issue)/i.test(normalized);
|
||||
if (asksReview) {
|
||||
return 'review-evaluation';
|
||||
}
|
||||
|
||||
const asksImplementation = /(고쳐|수정|개선해|구현|추가|삭제|리팩토링|디버그|fix|implement|add|remove|refactor|debug)/i.test(normalized);
|
||||
if (asksImplementation) {
|
||||
return 'implementation';
|
||||
}
|
||||
|
||||
const explicitKnowledgeCreation = /((?:이|그|현재|해당)?\s*(?:프로젝트|프로그램|코드베이스).{0,20}(?:대한|기반|관련).{0,20}지식.{0,12}(?:만들|생성|정리|문서화|기록|저장))|(지식.{0,12}(?:만들|생성|정리|문서화|기록|저장).{0,20}(?:프로젝트|프로그램|코드베이스))|(project\s+knowledge.{0,20}(?:create|generate|record|document|overview))|((?:create|generate|record|document).{0,20}project\s+knowledge)/i.test(normalized);
|
||||
if (explicitKnowledgeCreation) {
|
||||
return 'knowledge-creation';
|
||||
}
|
||||
|
||||
const asksDocumentation = /(문서화(?:해|해줘|를)|문서(?:로)?\s*(?:정리|작성|만들)|README|가이드|wiki|documentation|document\s+this|write\s+docs)/i.test(normalized);
|
||||
if (asksDocumentation) {
|
||||
return 'documentation';
|
||||
}
|
||||
|
||||
const asksThinking = /(설계|아키텍처|구조|방향|생각|의견|판단|어떤\s*거?\s*같|어때|architecture|design|structure|direction|opinion|think|judge)/i.test(normalized);
|
||||
if (asksThinking) {
|
||||
return 'thinking';
|
||||
}
|
||||
|
||||
return 'general';
|
||||
}
|
||||
|
||||
export function isProjectKnowledgeCreationRequest(prompt: string): boolean {
|
||||
return classifyLocalProjectIntent(prompt) === 'knowledge-creation';
|
||||
}
|
||||
|
||||
export function isProjectReviewEvaluationRequest(prompt: string): boolean {
|
||||
return classifyLocalProjectIntent(prompt) === 'review-evaluation';
|
||||
}
|
||||
|
||||
/**
|
||||
* Intent 별로 모델에게 어떤 출력 contract 를 강제할지 적는 가이던스 블록.
|
||||
* `review-evaluation` 만 길고 strict (사용자가 옛 리뷰가 template 같다고 피드백한
|
||||
* 항목들 일일이 반영), 나머지 intent 는 간결한 contract.
|
||||
*/
|
||||
export function buildLocalProjectIntentGuidance(intent: LocalProjectIntent): string {
|
||||
switch (intent) {
|
||||
case 'review-evaluation':
|
||||
return [
|
||||
'Intent operating contract — Code Review:',
|
||||
'The user wants a real review, not a meta-plan of how to review.',
|
||||
'OUTPUT FORMAT: PLAIN TEXT only. Section labels are bare words on their own line (no "#", "##", "**", "__", "> "). Bullets use "- ". Long answers MUST start with a "핵심 요약" block (2~4 bullets) before any detail.',
|
||||
'Required sections in this exact order, in Korean (each label appears as a plain line, NOT a markdown heading):',
|
||||
' 1) 한 줄 판단 — one sentence: would you rely on this today, and under what constraint?',
|
||||
' 2) 잘된 점 — 2~4 concrete strengths. Each MUST cite a specific file path (and a function or section if you can name one) and explain WHY it works, not just that it exists.',
|
||||
' 3) 부족한 점 — 2~4 concrete weaknesses or risks. Same rule: cite a specific file/area, name the actual problem (race condition, missing retry, coupling, etc.), and say what breaks because of it.',
|
||||
' 4) 사용자 관점 개선 — 2~4 changes phrased from the END USER\'s perspective ("when X happens, the user currently sees Y; they should see Z"). Tie each to a code location that needs to change.',
|
||||
' 5) 다음 한 수 — exactly one next action, small enough to do this week.',
|
||||
'',
|
||||
'Hard rules — these are the things that made past reviews feel like a template:',
|
||||
'- Do NOT write meta-sentences like "확인해야 합니다", "다음 리뷰에서는 ~를 보면 됩니다", "~로 보입니다", "~인지 확인하는 것이 핵심입니다". Either you observed it or you read the file with <read_file> right now.',
|
||||
'- Do NOT list the file structure tree back to the user — they already see it. Reference files only when making a specific claim.',
|
||||
'- Do NOT use the words "blind spot", "파이프라인 안정화", "골격은 있습니다" — these are tells of the old canned response.',
|
||||
'- If a file preview is insufficient to support a claim, USE <read_file path="..."> immediately to read it before writing the section. Do not hedge with "preview만으로는 판단할 수 없습니다".',
|
||||
'- Strengths and weaknesses must be SPECIFIC to this project. A sentence that would still be true if you swapped the project name is not allowed.',
|
||||
'- Skip every section that has nothing concrete to say. Better to write 잘된 점 with 2 strong items than 4 weak ones.',
|
||||
].join('\n');
|
||||
case 'knowledge-creation':
|
||||
return [
|
||||
'Intent operating contract:',
|
||||
'- Create a reusable project knowledge note from inspected evidence.',
|
||||
'- Do not ask for scope if the path is accessible; choose a small MVP overview by default.',
|
||||
'- Separate confirmed structure from inferred purpose and next deep-dive targets.',
|
||||
].join('\n');
|
||||
case 'implementation':
|
||||
return [
|
||||
'Intent operating contract:',
|
||||
'- Treat this as a change request, not advice.',
|
||||
'- Inspect the relevant files, make the smallest safe implementation, and verify it.',
|
||||
'- Preserve unrelated user changes.',
|
||||
].join('\n');
|
||||
case 'documentation':
|
||||
return [
|
||||
'Intent operating contract:',
|
||||
'- Produce or update documentation from inspected evidence.',
|
||||
'- Separate user-facing usage docs from internal architecture notes.',
|
||||
'- Avoid claiming behavior that is not visible in code or existing docs.',
|
||||
].join('\n');
|
||||
case 'thinking':
|
||||
return [
|
||||
'Intent operating contract:',
|
||||
'- Act as a thinking partner.',
|
||||
'- Give a direct opinion, then split confirmed facts, inferences, risks, decision forks, and one next move.',
|
||||
'- Avoid generic encouragement.',
|
||||
].join('\n');
|
||||
default:
|
||||
return [
|
||||
'Intent operating contract:',
|
||||
'- Use the inspected local files as grounding.',
|
||||
'- If the user request is ambiguous, answer the most likely project-oriented task and state the assumption.',
|
||||
].join('\n');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* "Astra Stance Layer" — 모델이 template-style 답변이 아니라 *시니어 협업자 톤*
|
||||
* 으로 직접 의견을 내도록 톤·접근법을 prepend. intent 가 review/thinking 이면
|
||||
* 더 강하게 opinionated, 나머지는 light persona.
|
||||
*/
|
||||
export function buildAstraStanceContext(prompt: string, localPathContext: string): string {
|
||||
const intent = localPathContext ? classifyLocalProjectIntent(prompt) : 'general';
|
||||
const wantsThinkingPartner = isThinkingPartnerRequest(prompt) || intent === 'review-evaluation' || intent === 'thinking';
|
||||
|
||||
const lines = [
|
||||
'[ASTRA STANCE LAYER]',
|
||||
'Use this to make the response feel like Astra thinking with the user, not a template being filled.',
|
||||
'',
|
||||
'Voice:',
|
||||
'- Warm, direct, and grounded. Do not over-explain the framework.',
|
||||
'- Prefer sentences that sound like a senior collaborator: "나는 여기서 X를 먼저 볼 것 같아요" / "이건 좋아요, 그런데 위험은 Y예요."',
|
||||
'- Avoid sterile balance like "장단점이 있습니다" unless you immediately make a call.',
|
||||
'',
|
||||
'Judgment habits:',
|
||||
'- State the real bet you think the user is making.',
|
||||
'- Name one thing to keep, one thing to cut, and one thing to verify next when relevant.',
|
||||
'- Use the user’s own goal as the yardstick, not generic best practice.',
|
||||
'- If there are many possible improvements, choose the one that compounds the project fastest.',
|
||||
'',
|
||||
wantsThinkingPartner
|
||||
? 'For this request, be especially opinionated. Give a clear personal verdict before structure.'
|
||||
: 'For this request, keep the persona light but still make concrete choices.',
|
||||
intent !== 'general' ? `Local project intent for tone: ${intent}` : '',
|
||||
];
|
||||
|
||||
if (intent === 'review-evaluation') {
|
||||
lines.push(
|
||||
'',
|
||||
'Review stance:',
|
||||
'- Do not merely list strengths and weaknesses. Say whether you would rely on this project today and under what constraint.',
|
||||
'- Prefer the product-owner question: "What has to become boring and reliable before this deserves expansion?"',
|
||||
'- If evidence is shallow, say which file would change your opinion most.',
|
||||
);
|
||||
}
|
||||
|
||||
if (intent === 'thinking') {
|
||||
lines.push(
|
||||
'',
|
||||
'Thinking stance:',
|
||||
'- Do not solve every branch. Reduce the user’s uncertainty to the next decision.',
|
||||
'- A useful answer may say: "I would not expand yet" or "This deserves a spike, not a feature."',
|
||||
);
|
||||
}
|
||||
|
||||
return lines.filter(Boolean).join('\n');
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { summarizeText } from '../../utils';
|
||||
import { EXCLUDED_DIRS } from '../../config';
|
||||
import { validatePath } from '../../security';
|
||||
import {
|
||||
POSIX_ABS_PATH_SRC,
|
||||
WIN_ABS_PATH_SRC,
|
||||
shouldPreflightLocalProjectPath,
|
||||
classifyLocalProjectIntent,
|
||||
buildLocalProjectIntentGuidance,
|
||||
} from './localProjectIntent';
|
||||
|
||||
/**
|
||||
* "로컬 프로젝트 경로 preflight" 클러스터 — 사용자 prompt 에 로컬 path 가 있으면
|
||||
* 디스크를 직접 scan 해서 트리/주요 파일 미리보기를 system prompt 에 prepend.
|
||||
*
|
||||
* 흐름:
|
||||
* 1) extractLocalProjectPaths — prompt 에서 경로 후보 추출 (절대 + 흔한 상대)
|
||||
* 2) inspectLocalProjectPath — 각 후보를 fs 로 읽어 tree + priority preview 생성
|
||||
* - listProjectTree — depth/limit 제한 트리 직렬화
|
||||
* - findPriorityProjectFiles — package.json/README/src 등 가중치 정렬
|
||||
* 3) buildLocalProjectPathContext — 위 결과 + intent guidance + critical directives 조립
|
||||
* 4) enforceLocalPathReviewAnswer — 모델이 "코드를 제공해주세요" 류 회피 답변을
|
||||
* 만들었을 때 그 문장들을 잘라내고 "스스로 read_file 하겠다" 헤더로 덮어쓰기
|
||||
*
|
||||
* Why one module: 6개 메서드가 한 사용 흐름의 단계라서 분리할수록 import 만 많아짐.
|
||||
* 모두 stateless — agent.ts 의 private 메서드를 그대로 추출.
|
||||
*
|
||||
* Notes:
|
||||
* - extractLocalProjectPaths 는 /g/ flag 패턴이 필요해 매 호출마다 새 RegExp
|
||||
* 인스턴스를 만든다 (lastIndex pollution 방지). source string 만 import.
|
||||
* - inspectLocalProjectPath 는 코드/문서 파일이면 8000자, 그 외 2000자 preview.
|
||||
* prompt token 폭증 방지 + 분석 가능성 둘 다 챙기는 합의점.
|
||||
*/
|
||||
|
||||
/** 사용자 prompt 에서 로컬 경로 후보들을 추출 (절대 경로 + 흔한 상대 경로). */
|
||||
export function extractLocalProjectPaths(prompt: string, rootPath?: string): string[] {
|
||||
const results: string[] = [];
|
||||
const stripTrailingPunct = (s: string) => s.replace(/[),.;\]]+$/g, '');
|
||||
|
||||
// 1a. POSIX 절대 경로
|
||||
const absMatches = prompt.match(new RegExp(POSIX_ABS_PATH_SRC, 'gi')) || [];
|
||||
for (const m of absMatches) {
|
||||
results.push(stripTrailingPunct(m));
|
||||
}
|
||||
// 1b. Windows 절대 경로
|
||||
const winMatches = prompt.match(new RegExp(WIN_ABS_PATH_SRC, 'gi')) || [];
|
||||
for (const m of winMatches) {
|
||||
results.push(stripTrailingPunct(m));
|
||||
}
|
||||
|
||||
// 2. 상대 경로 감지: src/lib/engine.ts, components\App.tsx 등
|
||||
const relMatches = prompt.match(/(?:^|[\s,])(?:(?:src|lib|components|pages|app|tests|test|utils|core|features|hooks|services|config|public|assets|docs|scripts)[\\/][^\s`"'<>]+\.[a-z]{1,6})/gi) || [];
|
||||
for (const m of relMatches) {
|
||||
const cleaned = m.trim().replace(/^,\s*/, '').replace(/[),.;\]]+$/g, '');
|
||||
if (rootPath) {
|
||||
const absPath = path.resolve(rootPath, cleaned);
|
||||
if (fs.existsSync(absPath)) {
|
||||
results.push(absPath);
|
||||
} else {
|
||||
// 프로젝트 루트 하위 sub-project 들에서도 검색
|
||||
const subProjects = ['ConnectAI', 'Datacollector_MAC', 'Agent', 'skybound'];
|
||||
let found = false;
|
||||
for (const sub of subProjects) {
|
||||
const subPath = path.resolve(rootPath, sub, cleaned);
|
||||
if (fs.existsSync(subPath)) {
|
||||
results.push(subPath);
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!found) {
|
||||
results.push(absPath); // fallback: 원래 경로 그대로
|
||||
}
|
||||
}
|
||||
} else {
|
||||
results.push(cleaned);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(new Set(results));
|
||||
}
|
||||
|
||||
/**
|
||||
* Depth/limit 제한 트리 직렬화. EXCLUDED_DIRS 와 hidden file 제외. recursive 호출
|
||||
* 이지만 lines.length 누적으로 cap 보장.
|
||||
*/
|
||||
export function listProjectTree(root: string, current: string, depth: number, maxDepth: number, limit: number): string {
|
||||
if (limit <= 0 || depth > maxDepth) {
|
||||
return '';
|
||||
}
|
||||
|
||||
let entries: fs.Dirent[] = [];
|
||||
try {
|
||||
entries = fs.readdirSync(current, { withFileTypes: true })
|
||||
.filter((entry) => !entry.name.startsWith('.') && !EXCLUDED_DIRS.has(entry.name))
|
||||
.sort((a, b) => Number(b.isDirectory()) - Number(a.isDirectory()) || a.name.localeCompare(b.name));
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
|
||||
const lines: string[] = [];
|
||||
for (const entry of entries) {
|
||||
if (lines.length >= limit) break;
|
||||
const fullPath = path.join(current, entry.name);
|
||||
const relative = path.relative(root, fullPath);
|
||||
lines.push(`${' '.repeat(depth)}${relative}${entry.isDirectory() ? '/' : ''}`);
|
||||
if (entry.isDirectory() && depth < maxDepth) {
|
||||
const child = listProjectTree(root, fullPath, depth + 1, maxDepth, limit - lines.length);
|
||||
if (child) {
|
||||
lines.push(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Priority 파일 (package.json, README, tsconfig, src/**, docs/**, config 등) 을
|
||||
* 가중치 정렬해 반환. visit 함수가 source area 진입 여부를 dfs 로 추적해서 src/
|
||||
* 안의 코드/문서 파일까지 잡되, 그 외에선 흔히 보는 config 파일만 챙긴다.
|
||||
*/
|
||||
export function findPriorityProjectFiles(root: string): string[] {
|
||||
const exactNames = new Set([
|
||||
'package.json',
|
||||
'README.md',
|
||||
'readme.md',
|
||||
'tsconfig.json',
|
||||
'vite.config.ts',
|
||||
'vite.config.js',
|
||||
'next.config.js',
|
||||
'next.config.mjs',
|
||||
'webpack.config.js',
|
||||
]);
|
||||
const results: string[] = [];
|
||||
const visit = (dir: string, depth: number, inSourceArea: boolean) => {
|
||||
if (depth > 6 || results.length >= 24) return;
|
||||
let entries: fs.Dirent[] = [];
|
||||
try {
|
||||
entries = fs.readdirSync(dir, { withFileTypes: true })
|
||||
.filter((entry) => !entry.name.startsWith('.') && !EXCLUDED_DIRS.has(entry.name));
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
const nextInSourceArea = inSourceArea || /^(src|app|pages|components|docs|lib|server|backend|frontend|config|features|core|hooks|systems|store|model|utils|ui|api)$/i.test(entry.name);
|
||||
if (nextInSourceArea) {
|
||||
visit(fullPath, depth + 1, nextInSourceArea);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const relative = path.relative(root, fullPath);
|
||||
const isSourceCode = /\.(ts|tsx|js|jsx)$/i.test(entry.name);
|
||||
if (
|
||||
exactNames.has(entry.name)
|
||||
|| (inSourceArea && isSourceCode)
|
||||
|| /(^|[\\/])(src|app|pages|components|docs|lib|server|backend|frontend|features|core)[\\/].+\.(ts|tsx|js|jsx|md|json)$/i.test(relative)
|
||||
|| /\.(config|rc)\.(js|ts|json)$/i.test(entry.name)
|
||||
) {
|
||||
results.push(fullPath);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
visit(root, 0, false);
|
||||
return Array.from(new Set(results)).sort((a, b) => {
|
||||
const rank = (file: string) => {
|
||||
const relative = path.relative(root, file);
|
||||
if (path.basename(file) === 'package.json') return 0;
|
||||
if (/readme\.md$/i.test(file)) return 1;
|
||||
if (/^src[\\/]App\.tsx$/i.test(relative)) return 2;
|
||||
if (/^src[\\/]main\.tsx$/i.test(relative)) return 3;
|
||||
if (/^src[\\/]features[\\/]game[\\/]hooks[\\/]useGameEngine\.ts$/i.test(relative)) return 4;
|
||||
if (/^src[\\/]features[\\/]game[\\/]systems[\\/]/i.test(relative)) return 5;
|
||||
if (/^src[\\/]features[\\/]game[\\/]ui[\\/]/i.test(relative)) return 6;
|
||||
if (/^src[\\/]/i.test(relative)) return 7;
|
||||
if (/^docs[\\/]|\.md$/i.test(relative)) return 8;
|
||||
return 9;
|
||||
};
|
||||
return rank(a) - rank(b) || a.localeCompare(b);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 단일 경로를 inspect — 디렉토리면 tree + priority preview, 파일이면 내용 preview.
|
||||
* 코드/문서 파일은 8000자, 그 외 2000자 cap (token 폭증 방지).
|
||||
*/
|
||||
export function inspectLocalProjectPath(targetPath: string, rootPath: string): string {
|
||||
try {
|
||||
const absPath = validatePath(rootPath, targetPath);
|
||||
if (!fs.existsSync(absPath)) {
|
||||
return [
|
||||
`Path: ${targetPath}`,
|
||||
'Access: failed',
|
||||
'Reason: path does not exist in the current environment.',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
const stat = fs.statSync(absPath);
|
||||
if (!stat.isDirectory()) {
|
||||
const content = fs.readFileSync(absPath, 'utf8');
|
||||
const fileName = path.basename(absPath);
|
||||
const ext = path.extname(absPath).toLowerCase();
|
||||
const isCodeOrDoc = ['.ts', '.js', '.tsx', '.jsx', '.py', '.java', '.go', '.rs', '.md', '.json', '.yaml', '.yml', '.toml', '.css', '.html', '.sql', '.sh', '.zsh', '.env', '.xml', '.swift', '.kt'].includes(ext);
|
||||
const previewLimit = isCodeOrDoc ? 8000 : 2000;
|
||||
return [
|
||||
`Path: ${targetPath}`,
|
||||
'Access: succeeded',
|
||||
`Type: file (${fileName})`,
|
||||
`Size: ${content.length} characters`,
|
||||
`Full content (${content.length <= previewLimit ? 'complete' : `first ${previewLimit} chars`}):\n\`\`\`${ext.slice(1)}\n${summarizeText(content, previewLimit)}\n\`\`\``,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
const tree = listProjectTree(absPath, absPath, 0, 4, 140);
|
||||
const priorityFiles = findPriorityProjectFiles(absPath).slice(0, 12);
|
||||
const previews = priorityFiles.map((file) => {
|
||||
try {
|
||||
const content = fs.readFileSync(file, 'utf8');
|
||||
return [
|
||||
`File: ${path.relative(absPath, file)}`,
|
||||
summarizeText(content, 2200),
|
||||
].join('\n');
|
||||
} catch (error: any) {
|
||||
return `File: ${path.relative(absPath, file)}\nRead failed: ${error.message}`;
|
||||
}
|
||||
}).join('\n\n');
|
||||
|
||||
return [
|
||||
`Path: ${targetPath}`,
|
||||
'Access: succeeded',
|
||||
'Type: directory',
|
||||
`Scanned tree:\n${tree || '(no visible files found)'}`,
|
||||
priorityFiles.length > 0
|
||||
? `Priority file previews:\n${previews}`
|
||||
: 'Priority file previews: no package, README, docs, src, or config files found in the first scan.',
|
||||
].join('\n');
|
||||
} catch (error: any) {
|
||||
return [
|
||||
`Path: ${targetPath}`,
|
||||
'Access: failed',
|
||||
`Reason: ${error.message}`,
|
||||
].join('\n');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Preflight 결과를 system prompt 에 prepend 할 큰 블록으로 조립. preflight 가
|
||||
* skip 조건이면 빈 문자열. 최대 5개 후보까지만 inspect (token cap).
|
||||
*/
|
||||
export function buildLocalProjectPathContext(prompt: string, rootPath: string): string {
|
||||
if (!shouldPreflightLocalProjectPath(prompt)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const candidates = extractLocalProjectPaths(prompt, rootPath);
|
||||
if (candidates.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const intent = classifyLocalProjectIntent(prompt);
|
||||
const sections: string[] = [
|
||||
'[LOCAL PROJECT PATH PREFLIGHT]',
|
||||
`Local project intent: ${intent}`,
|
||||
buildLocalProjectIntentGuidance(intent),
|
||||
'[CRITICAL DIRECTIVE] The file structure and snippets below are an INITIAL scan from the local filesystem.',
|
||||
'If you need to see the full content of any file or explore other directories to perform the analysis, you MUST use the <read_file path="..."> or <list_files path="..."> action tags immediately in your response.',
|
||||
'DO NOT ask the user to provide, upload, paste, or share the file contents. DO NOT ask for permission to read them. Just use the action tags to read them yourself.',
|
||||
'DO NOT say "파일 내용을 보여주세요", "코드를 공유해 주세요", or "파일을 제공해 주세요".',
|
||||
'Proceed IMMEDIATELY with analysis or with using action tags to gather more context. Do not ask for confirmation like "진행할까요?" or "분석을 시작할까요?". Just do it.',
|
||||
'If multiple files are mentioned, analyze them sequentially in the order the user specified without pausing for confirmation between each.',
|
||||
'The user provided a local project path for review, analysis, documentation, or knowledge creation. Use this inspected context, and if needed use <read_file> to dig deeper before answering.',
|
||||
'If access failed, explain the concrete failure.',
|
||||
'If access succeeded and priority file previews are present, do not say that code was not provided.',
|
||||
'Treat the Local project intent line as the routing decision for this response.',
|
||||
'If intent is review-evaluation, do not create a project knowledge note. Review the inspected project as the primary task: strengths, weaknesses, risks, and extensibility.',
|
||||
'If intent is knowledge-creation, answer that the project can be summarized from the inspected local path and propose or execute a project knowledge note based on the previews.',
|
||||
'If intent is thinking, act as a project thinking partner and give a clear verdict grounded in the inspected files.',
|
||||
];
|
||||
|
||||
for (const candidate of candidates.slice(0, 5)) {
|
||||
sections.push(inspectLocalProjectPath(candidate, rootPath));
|
||||
}
|
||||
|
||||
return sections.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* 모델이 "코드를 제공해주세요 / 업로드해주세요" 류 회피 답변을 만들었을 때,
|
||||
* 그 문장들을 잘라내고 "스스로 read_file 하겠다" 헤더로 덮어쓰기. localPathContext
|
||||
* 가 access 실패면 noop (정당한 거절일 수 있음).
|
||||
*/
|
||||
export function enforceLocalPathReviewAnswer(content: string, localPathContext: string): string {
|
||||
if (!localPathContext.includes('Access: succeeded')) {
|
||||
return content;
|
||||
}
|
||||
|
||||
const asksForUpload = /(코드(?:를|가)?\s*업로드|파일(?:을|를)?\s*업로드|소스\s*코드(?:를)?\s*업로드|코드를 제공|파일을 제공|핵심 파일(?:이나|과|을|를)?.*제공|파일 목록(?:이나|과|을|를)?.*제공|구조(?:를|와|나)?.*제공|자료(?:를|가)?.*필요|folder path is not enough|upload (?:the )?(?:source )?code|please provide (?:the )?files|먼저 분석할까요|살펴볼까요)/i.test(content);
|
||||
const deniesCodeAccess = /(실제 코드 내용이 없|코드 내용이 없|코드가 없|코드를 볼 수 없|소스 코드를 볼 수 없|실제 구현 자료가 없|실제 구현 근거 없이는|현재로서는.*자료가 없|기술적인 진단.*수 없습니다|코드를 읽어야만|파일 구조만으로는.*판단할 수 없|코드의 논리적 흐름.*판단할 수 없)/i.test(content);
|
||||
if (!asksForUpload && !deniesCodeAccess) {
|
||||
return content;
|
||||
}
|
||||
|
||||
const header = [
|
||||
'## 경로 확인 결과',
|
||||
'',
|
||||
'제공된 로컬 프로젝트 경로에는 접근할 수 있고, 코드 파일도 일부 확인되었습니다. 만약 추가적인 코드 확인이 필요하다면 <read_file> 이나 <list_files> 액션 태그를 즉시 사용하여 스스로 파일을 읽어보고 분석을 진행하겠습니다.',
|
||||
'',
|
||||
'이전 응답에서 "파일을 제공해주세요" 라거나 "먼저 분석할까요?" 라고 묻는 것은 잘못된 안내입니다. 액션 태그를 통해 스스로 필요한 코드를 열어보겠습니다.',
|
||||
].join('\n');
|
||||
|
||||
return [
|
||||
header,
|
||||
'',
|
||||
content
|
||||
.replace(/.*(?:코드(?:를|가)?\s*업로드|파일(?:을|를)?\s*업로드|소스\s*코드(?:를)?\s*업로드|코드를 제공|파일을 제공).*$/gmi, '')
|
||||
.replace(/.*(?:핵심 파일(?:이나|과|을|를)?.*제공|파일 목록(?:이나|과|을|를)?.*제공|구조(?:를|와|나)?.*제공|자료(?:를|가)?.*필요).*$/gmi, '')
|
||||
.replace(/.*(?:실제 코드 내용이 없|코드 내용이 없|코드가 없|코드를 볼 수 없|소스 코드를 볼 수 없|실제 구현 자료가 없|실제 구현 근거 없이는|현재로서는.*자료가 없|코드를 읽어야만|파일 구조만으로는.*판단할 수 없).*$/gmi, '')
|
||||
.replace(/.*(?:먼저 분석할까요|살펴볼까요).*$/gmi, '')
|
||||
.trim(),
|
||||
].filter(Boolean).join('\n\n');
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
import * as path from 'path';
|
||||
import * as vscode from 'vscode';
|
||||
import type { ChatMessage } from '../../agent';
|
||||
import type { BrainProfile } from '../../config';
|
||||
import { getConfig } from '../../config';
|
||||
import type { MemoryManager } from '../../memory';
|
||||
import type { RetrievalOrchestrator } from '../../retrieval';
|
||||
import { buildLessonChecklistBlock } from '../../retrieval/lessonHelpers';
|
||||
import { embedQuery, embedTexts } from '../../retrieval/embeddings';
|
||||
import { backfillBrainEmbeddings } from '../../retrieval/brainIndex';
|
||||
import { resolveScopeForAgent } from '../../skills/agentKnowledgeMap';
|
||||
import {
|
||||
resolveKnowledgeMix,
|
||||
mapWeightToBrainFileLimit,
|
||||
mapWeightToRetrievalRatio,
|
||||
ResolvedKnowledgeMix,
|
||||
} from '../../retrieval/knowledgeMix';
|
||||
|
||||
/**
|
||||
* 한 turn 의 RAG / 5-layer memory 컨텍스트 빌드.
|
||||
*
|
||||
* 옛 코드: agent.ts 의 130줄짜리 private `buildMemoryContext`. 인스턴스 state 6개
|
||||
* (memoryManager, chatHistory, retrievalOrchestrator, context, currentTaskId,
|
||||
* _turnCtx) 에 의존해 god-file 의 일부였음.
|
||||
*
|
||||
* 분리 방식: 호출자(provider) 가 모은 deps struct 를 받는 *순수 orchestration*
|
||||
* 함수로 격리. RetrievalOrchestrator / MemoryManager 자체는 그대로 둠 (이 함수는
|
||||
* 그 두 객체의 *사용 패턴* 만 표준화). 사이드 이펙트 두 가지는 명시:
|
||||
* 1) `deps.turnCtx` mutation — webview footer 가 읽는 retrieval/lessons/knowledgeMix.
|
||||
* 2) `backfillBrainEmbeddings` fire-and-forget — 다음 turn 의 score 향상용.
|
||||
*
|
||||
* 의도: agent.ts 로부터 130줄 빼면서 RAG 호출 패턴을 단위 테스트 대상 함수로 노출.
|
||||
* Provider 는 deps 만 채워 호출하면 되도록 줄임.
|
||||
*/
|
||||
|
||||
/** TurnContext 의 retrieval 슬롯 모양. provider 의 `_turnCtx.retrieval` 와 일치해야 함. */
|
||||
export interface TurnRetrievalSummary {
|
||||
agentName: string | null;
|
||||
scoped: boolean;
|
||||
source: string;
|
||||
configuredFolders: string[];
|
||||
usedBrainFiles: string[];
|
||||
usedMemoryLayers: string[];
|
||||
lessonFiles: string[];
|
||||
totalChunks: number;
|
||||
selectedChunks: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mutable turn-context sink — 호출자의 `_turnCtx` 와 같은 객체를 그대로 받아 함수
|
||||
* 안에서 채워준다. 매 호출 직전에 호출자가 `reset` 해서 비워야 함.
|
||||
*/
|
||||
export interface TurnContextSink {
|
||||
retrieval: TurnRetrievalSummary | null;
|
||||
lessons: string[];
|
||||
knowledgeMix: ResolvedKnowledgeMix | null;
|
||||
}
|
||||
|
||||
export interface MemoryContextDeps {
|
||||
currentPrompt: string;
|
||||
activeBrain: BrainProfile;
|
||||
agentSkillFile?: string;
|
||||
/** Visible + internal 합친 raw chat history. 함수 안에서 internal 필터링. */
|
||||
chatHistory: ChatMessage[];
|
||||
memoryManager: MemoryManager;
|
||||
retrievalOrchestrator: RetrievalOrchestrator;
|
||||
/** vscode ExtensionContext — chat_sessions globalState 읽기에 사용. */
|
||||
context: vscode.ExtensionContext;
|
||||
/** 현재 turn 의 session id — recentSessions 에서 자기 자신 제외. */
|
||||
currentTaskId: string;
|
||||
/** 함수가 채울 turn-context sink. 호출자는 호출 전에 비워둬야 한다. */
|
||||
turnCtx: TurnContextSink;
|
||||
}
|
||||
|
||||
/**
|
||||
* 영구 저장된 chat_sessions 풀에서 medium-term 후보를 추리는 compact helper.
|
||||
* 활성 세션 자신은 제외, 빈 history 도 제외, 짧은 미리보기/요약만 보관해
|
||||
* orchestrator 입력에 들어가도 토큰 폭증 안 함.
|
||||
*/
|
||||
function compactRecentSessions(
|
||||
rawSessions: any[],
|
||||
activeSessionId: string | null,
|
||||
limit: number,
|
||||
): Array<{ id: string; title: string; firstUserMsg: string; lastAssistantExcerpt: string; summary?: string; timestamp: number }> {
|
||||
if (!Array.isArray(rawSessions) || rawSessions.length === 0 || limit <= 0) return [];
|
||||
const pool = rawSessions.length > limit + 5 ? limit + 5 : rawSessions.length;
|
||||
const out: Array<{ id: string; title: string; firstUserMsg: string; lastAssistantExcerpt: string; summary?: string; timestamp: number }> = [];
|
||||
for (let i = 0; i < rawSessions.length && out.length < pool; i++) {
|
||||
const s = rawSessions[i];
|
||||
if (!s || typeof s !== 'object') continue;
|
||||
const id = String(s.id ?? '');
|
||||
if (!id || id === activeSessionId) continue;
|
||||
const history: any[] = Array.isArray(s.history) ? s.history : [];
|
||||
if (history.length === 0) continue;
|
||||
const firstUser = history.find((m) => m?.role === 'user');
|
||||
const lastAssistant = [...history].reverse().find((m) => m?.role === 'assistant');
|
||||
const firstUserMsg = String(firstUser?.content ?? '').replace(/\s+/g, ' ').trim().slice(0, 200);
|
||||
const lastTxt = String(lastAssistant?.content ?? '').replace(/\s+/g, ' ').trim();
|
||||
const lastAssistantExcerpt = lastTxt.length <= 200 ? lastTxt : lastTxt.slice(-200);
|
||||
const summary = typeof s.summary === 'string' ? s.summary.trim().slice(0, 600) : undefined;
|
||||
if (!firstUserMsg && !lastAssistantExcerpt && !summary) continue;
|
||||
out.push({
|
||||
id,
|
||||
title: String(s.title ?? '').trim() || firstUserMsg.slice(0, 50),
|
||||
firstUserMsg,
|
||||
lastAssistantExcerpt,
|
||||
summary,
|
||||
timestamp: typeof s.timestamp === 'number' ? s.timestamp : 0,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function buildMemoryContext(deps: MemoryContextDeps): Promise<string> {
|
||||
const config = getConfig();
|
||||
if (!config.memoryEnabled) return '';
|
||||
|
||||
// Settings 가 turn 사이에 바뀔 수 있으니 매번 동기화.
|
||||
deps.memoryManager.updateConfig({
|
||||
enabled: config.memoryEnabled,
|
||||
shortTermLimit: config.memoryShortTermMessages,
|
||||
});
|
||||
|
||||
const visibleHistory = deps.chatHistory.filter((message) => !message.internal);
|
||||
const workspaceFolders = vscode.workspace.workspaceFolders;
|
||||
const workspacePath = workspaceFolders ? workspaceFolders[0].uri.fsPath : undefined;
|
||||
|
||||
// Agent ↔ knowledge map. 매핑 없으면 folders=[] → orchestrator 가 whole-brain 사용 (legacy).
|
||||
const scope = resolveScopeForAgent(deps.agentSkillFile, deps.activeBrain.localBrainPath);
|
||||
|
||||
// Context 윈도우 비례 retrieval 예산. 32K → 8K, 230K → 57K, 80K cap (scoring 속도).
|
||||
const scaledTotalBudget = Math.min(
|
||||
80000,
|
||||
Math.max(8000, Math.floor(config.contextLength * 0.25)),
|
||||
);
|
||||
|
||||
// medium-term layer 용 옛 세션 후보. sidebar 가 직접 쓰는 key 를 read-through.
|
||||
const rawSessions = deps.context.globalState.get<any[]>('chat_sessions', []) || [];
|
||||
const recentSessions = compactRecentSessions(
|
||||
rawSessions,
|
||||
deps.currentTaskId,
|
||||
Math.max(0, config.memoryMediumTermSessions ?? 0),
|
||||
);
|
||||
|
||||
// Hybrid retrieval (옵션): embedding model 있으면 query embedding 가져와 cosine
|
||||
// + TF-IDF blend. timeout 4초 — endpoint 가 느리면 그냥 pure TF-IDF 로 진행.
|
||||
let queryEmbedding: number[] | undefined;
|
||||
if (config.embeddingModel) {
|
||||
const EMBED_QUERY_TIMEOUT_MS = 4000;
|
||||
try {
|
||||
queryEmbedding = await Promise.race([
|
||||
embedQuery(deps.currentPrompt, { baseUrl: config.ollamaUrl, model: config.embeddingModel }),
|
||||
new Promise<undefined>((resolve) => setTimeout(() => resolve(undefined), EMBED_QUERY_TIMEOUT_MS)),
|
||||
]);
|
||||
} catch {
|
||||
queryEmbedding = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
// Knowledge Mix 가중치 (per-agent → global → default). weight=50 이면 legacy 기본값과 동일.
|
||||
const knowledgeMix = resolveKnowledgeMix(deps.agentSkillFile);
|
||||
deps.turnCtx.knowledgeMix = knowledgeMix;
|
||||
const mixedBrainFileLimit = mapWeightToBrainFileLimit(knowledgeMix.weight, config.memoryLongTermFiles);
|
||||
const mixedRetrievalRatio = mapWeightToRetrievalRatio(knowledgeMix.weight);
|
||||
|
||||
// Unified RAG Pipeline 호출.
|
||||
const result = deps.retrievalOrchestrator.retrieve(deps.currentPrompt, {
|
||||
brain: deps.activeBrain,
|
||||
memoryManager: deps.memoryManager,
|
||||
workspacePath,
|
||||
chatHistory: visibleHistory,
|
||||
contextBudget: {
|
||||
totalBudget: scaledTotalBudget,
|
||||
retrievalRatio: mixedRetrievalRatio,
|
||||
},
|
||||
brainFileLimit: mixedBrainFileLimit,
|
||||
scopeFolders: scope.folders,
|
||||
recentSessions,
|
||||
mediumTermLimit: config.memoryMediumTermSessions ?? 0,
|
||||
queryEmbedding,
|
||||
embeddingModel: config.embeddingModel || undefined,
|
||||
embeddingBlendAlpha: config.embeddingBlendAlpha,
|
||||
});
|
||||
|
||||
// Fire-and-forget background embedding. Vector 없는 파일만 embed 하므로
|
||||
// steady-state turn 은 작업량 0 — 다음 turn 이 혜택.
|
||||
if (config.embeddingModel) {
|
||||
const scoredFilePaths = result.selectedChunks
|
||||
.filter((c) => c.source === 'brain-memory' && c.metadata.filePath)
|
||||
.map((c) => c.metadata.filePath!)
|
||||
.filter((p, i, arr) => arr.indexOf(p) === i);
|
||||
if (scoredFilePaths.length > 0) {
|
||||
void backfillBrainEmbeddings(
|
||||
deps.activeBrain.localBrainPath,
|
||||
scoredFilePaths,
|
||||
config.embeddingModel,
|
||||
(texts) => embedTexts(texts, { baseUrl: config.ollamaUrl, model: config.embeddingModel }),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// webview "scope used" footer 가 읽는 turn-context summary. brain-trace 는
|
||||
// 검색이 아니라 trace 표시용이라 usedMemoryLayers 에서 제외 (brain-memory 도 제외 —
|
||||
// 별도 usedBrainFiles 로 표시).
|
||||
const brainRoot = deps.activeBrain.localBrainPath;
|
||||
const rel = (p?: string) => (p ? (path.relative(brainRoot, p) || p) : '');
|
||||
const lessonChunks = result.lessonChunks || [];
|
||||
deps.turnCtx.retrieval = {
|
||||
agentName: scope.agent?.name ?? null,
|
||||
scoped: scope.folders.length > 0,
|
||||
source: String((scope as any).source ?? ''),
|
||||
configuredFolders: scope.folders.map((abs) => rel(abs)),
|
||||
usedBrainFiles: result.selectedChunks
|
||||
.filter((c) => c.source === 'brain-memory' && c.metadata.filePath)
|
||||
.map((c) => rel(c.metadata.filePath))
|
||||
.filter((p, i, arr) => p && arr.indexOf(p) === i),
|
||||
usedMemoryLayers: Array.from(new Set(
|
||||
result.selectedChunks
|
||||
.filter((c) => c.source !== 'brain-memory' && c.source !== 'brain-trace')
|
||||
.map((c) => c.source as string),
|
||||
)),
|
||||
lessonFiles: lessonChunks.map((c) => rel(c.metadata.filePath)).filter((p, i, arr) => p && arr.indexOf(p) === i),
|
||||
totalChunks: result.totalChunks,
|
||||
selectedChunks: result.selectedChunks.length,
|
||||
};
|
||||
|
||||
deps.turnCtx.lessons = lessonChunks.map((c) => c.content);
|
||||
// Lessons 블록은 일반 RAG context 보다 앞 — context-overflow truncation 에서 먼저
|
||||
// 살아남게.
|
||||
const lessonBlock = buildLessonChecklistBlock(lessonChunks.map((c) => ({ title: c.title, content: c.content })));
|
||||
const memoryBlock = deps.retrievalOrchestrator.buildContextString(result);
|
||||
return [lessonBlock, memoryBlock].filter(Boolean).join('\n\n');
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* 사용자 지정 modelName 으로 추론 호출 시도할 때, 첫 호출이 404 / not-loaded
|
||||
* 류로 실패할 가능성에 대비한 fallback 후보 목록을 만든다.
|
||||
*
|
||||
* LM Studio 한정: `gemma3:4b` 같은 ":quant suffix" 형태가 안 먹히면 base name
|
||||
* (`gemma3`) 도 시도하게 한 줄 더 push. Ollama 는 항상 정확한 이름을 요구하므로
|
||||
* candidates 는 1개.
|
||||
*
|
||||
* Stateless — agent.ts 의 private 메서드 그대로 추출.
|
||||
*/
|
||||
export function buildModelCandidates(modelName: string, engine: 'lmstudio' | 'ollama'): string[] {
|
||||
const candidates = [modelName];
|
||||
if (engine === 'lmstudio') {
|
||||
const baseModel = modelName.replace(/:\d+$/, '');
|
||||
if (baseModel && baseModel !== modelName) {
|
||||
candidates.push(baseModel);
|
||||
}
|
||||
}
|
||||
return candidates;
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { getConfig } from '../../config';
|
||||
import { estimateTokens, estimateModelParamsB } from '../contextManager';
|
||||
import { isCasualConversationPrompt } from './promptDetection';
|
||||
import { isAstraModeArchitectureQuestion } from './astraModeArchitecture';
|
||||
import { shouldPreflightLocalProjectPath } from './localProjectIntent';
|
||||
|
||||
/**
|
||||
* 단일-LLM vs Multi-Agent (5단계 파이프라인) 라우팅 결정. 사용자가 명시 토글을
|
||||
* 켰거나 (configEnabled), 작은 모델인데 prompt 가 크거나, 복합 작업 키워드가
|
||||
* 매치되면 multi-agent 활성화. 잡담/짧은 prompt 는 무조건 단일 LLM.
|
||||
*
|
||||
* Priority (위→아래로 evaluated, 먼저 매치되면 그 결정 사용):
|
||||
* 1) Astra-mode meta question / local-path preflight → 단일 (false)
|
||||
* 2) mode='off' 인 사용자 — legacy 키워드+길이 휴리스틱만 사용
|
||||
* 3) casual prompt / 너무 짧음 (<12자) → 단일
|
||||
* 4) mode='always' → 무조건 multi (위 가드 통과 시)
|
||||
* 5) mode='auto' — 작은 모델 / context fraction / 키워드 / 길이 중 하나라도
|
||||
* 매치되면 multi. 단 prompt tokens 가 `chunkedSwitchTokens` 미만이면 *모든*
|
||||
* 트리거 무시하고 단일 (큰 context 모델에서 키워드만으로 chunked 강제 발동
|
||||
* 되는 회귀 차단 — 사용자 명시 요청).
|
||||
*
|
||||
* Stateless — agent.ts 의 private 메서드를 그대로 추출. 의존: config / token
|
||||
* 추정기 / 다른 detection 함수 (모두 이미 stateless).
|
||||
*/
|
||||
export function shouldUseMultiAgentWorkflow(prompt: string, configEnabled: boolean): boolean {
|
||||
if (!prompt || isAstraModeArchitectureQuestion(prompt)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (shouldPreflightLocalProjectPath(prompt)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const cfg = getConfig();
|
||||
const mode = cfg.workflowMultiAgentMode || 'auto';
|
||||
|
||||
// 'off' → 기존 키워드/길이 휴리스틱만 사용 (legacy multiAgentEnabled 토글 존중).
|
||||
if (mode === 'off') {
|
||||
const legacyComplex = prompt.length > 180 || /(보고서|심층|종합\s*분석|리서치|조사|전략\s*수립|기획안|제안서|roadmap|research|report|deep\s*analysis|strategy|proposal)/i.test(prompt);
|
||||
if (!legacyComplex) return false;
|
||||
return configEnabled || /(보고서|심층|종합\s*분석|리서치|조사|전략\s*수립|기획안|제안서|research|report|deep\s*analysis|strategy|proposal)/i.test(prompt);
|
||||
}
|
||||
|
||||
// 인사·잡담은 5단계 파이프라인 낭비. 짧은 casual prompt 는 제외.
|
||||
if (isCasualConversationPrompt(prompt)) {
|
||||
return false;
|
||||
}
|
||||
if (prompt.trim().length < 12) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 'always' → 위 가드만 통과하면 무조건 발동.
|
||||
if (mode === 'always') return true;
|
||||
|
||||
// 'auto' → 다음 중 하나라도 만족하면 발동:
|
||||
// (1) 사용자가 multiAgentEnabled 를 명시적으로 켰다,
|
||||
// (2) 작은 모델 (≤4B params) 이라 한 번에 처리하기 위험,
|
||||
// (3) prompt 토큰이 효과적 context window 의 임계 이상을 차지한다,
|
||||
// (4) "보고서/리뷰/심층 분석" 같은 명백한 복합 작업 키워드 매치,
|
||||
// (5) prompt 길이 자체가 큼 (>240 chars).
|
||||
if (configEnabled) return true;
|
||||
|
||||
const paramB = estimateModelParamsB(cfg.defaultModel);
|
||||
if (paramB !== null && paramB <= 4) return true;
|
||||
|
||||
// ── 절대 임계값 게이트 (사용자 명시 요청) ────────────────────────────
|
||||
// 입력 prompt 가 `chunkedSwitchTokens` 미만이면 *키워드·길이 트리거 모두 무시*
|
||||
// 하고 단일 LLM 호출. 큰 컨텍스트 모델(131k 등)에서 "요약/리뷰" 같은 키워드만
|
||||
// 써도 chunked 가 강제 발동해 답변이 느려지던 문제 해결.
|
||||
//
|
||||
// 이 게이트는 fraction 안전 체크보다 *먼저* 평가됨 — 사용자가 절대 임계값을
|
||||
// 명시한 의도(50k 미만은 한 번에 처리)를 fraction 이 뒤집지 못하게. 작은
|
||||
// 컨텍스트 모델 사용자는 config 에서 이 값을 모델 윈도우의 ~30% 로 낮춰야 함.
|
||||
try {
|
||||
const promptTokensForGate = estimateTokens(prompt);
|
||||
if (promptTokensForGate < cfg.chunkedSwitchTokens) {
|
||||
return false;
|
||||
}
|
||||
} catch { /* fall through — 안전 측 fraction/keyword 체크가 처리 */ }
|
||||
|
||||
try {
|
||||
const effectiveCtx = cfg.smallModelContextCap > 0 && paramB !== null && paramB <= 4
|
||||
? cfg.smallModelContextCap
|
||||
: cfg.contextLength;
|
||||
const promptTokens = estimateTokens(prompt);
|
||||
const threshold = Math.floor(effectiveCtx * cfg.workflowAutoCtxFractionThreshold);
|
||||
if (promptTokens >= threshold) return true;
|
||||
} catch { /* 안전한 폴백: 키워드/길이 체크로 진행 */ }
|
||||
|
||||
if (/(보고서|심층|종합\s*분석|리서치|조사|전략\s*수립|기획안|제안서|코드\s*리뷰|리뷰|아키텍처|architecture|research|report|deep\s*analysis|strategy|proposal|review)/i.test(prompt)) {
|
||||
return true;
|
||||
}
|
||||
if (prompt.length > 240) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* 모델 출력 후처리 (post-stream sanitization). 모두 stateless pure transform.
|
||||
*
|
||||
* 1) sanitizeAssistantContent — 모델이 답변에 흘려보낸 내부 마커 (rationale,
|
||||
* reasoning channels, Harmony/GPT-OSS 채널 마커, [PROBLEM]/[GOAL]/[REASONING]
|
||||
* 메타 섹션) 를 제거한 *깨끗한 본문* 만 남긴다.
|
||||
* 2) cleanDegeneratedOutput — 작은 모델이 long context 에서 degenerate 됐을 때
|
||||
* (문자 벽, 영문 메타 코멘트, 중복 paragraph stutter 등) 가독성 복구.
|
||||
* 모델 실패를 *고치는* 게 아니라 "나쁜 답변을 노이즈 벽이 아니라 읽을 수 있는
|
||||
* 형태로 유지" 하는 정도의 책임.
|
||||
* 3) isRestartedAnswer — continuation round 가 답변을 "이어붙이지" 않고 *처음부터*
|
||||
* 다시 시작했는지 detect. 작은 모델이 흔히 "continue from here" 지시를 무시.
|
||||
*
|
||||
* Why one module: 셋 다 *모델 출력을 읽을 만한 텍스트로 정리* 라는 단일 책임을
|
||||
* 공유. 호출 위치도 같은 stream loop 안에 모여 있어 응집도 높음.
|
||||
*/
|
||||
|
||||
/**
|
||||
* 모델 출력에서 *내부* 마커 / 메타 섹션을 제거. Harmony 스타일 채널 마커는
|
||||
* `final` 채널만 남기고 나머지 (thought, analysis, commentary, reasoning) 는 통째로
|
||||
* 제거 — 모델별 closing 형태가 달라 (`<channel|>`, `<|channel|>`, `<|end|>`,
|
||||
* `<|return|>`) 보수적으로 매칭.
|
||||
*/
|
||||
export function sanitizeAssistantContent(text: string): string {
|
||||
const stripped = text
|
||||
.replace(/<rationale>[\s\S]*?<\/rationale>/gi, '')
|
||||
.replace(/^\s*\[PROBLEM\][\s\S]*?\[GOAL\][\s\S]*?\[REASONING\][^\n]*(?:\n+|$)/i, '')
|
||||
.replace(/^\s*\[PROBLEM\][\s\S]*?(?:\n\s*\n|$)/i, '')
|
||||
.replace(/(?:<think(?:ing)?>|<analysis>)[\s\S]*?(?:<\/think(?:ing)?>|<\/analysis>)/gi, '')
|
||||
.replace(/<\|?channel\|?>\s*(?:thought|analysis|commentary|reasoning)\b[\s\S]*?<\|?channel\|?>/gi, '')
|
||||
.replace(/<\|?channel\|?>\s*(?:thought|analysis|commentary|reasoning)\b[\s\S]*?(?=<\|?channel\|?>\s*final\b)/gi, '')
|
||||
.replace(/<\|?channel\|?>\s*final\b\s*(?:<\|?message\|?>)?/gi, '')
|
||||
.replace(/<\|?(?:end|return|start|message)\|?>/gi, '')
|
||||
.trim();
|
||||
return cleanDegeneratedOutput(stripped);
|
||||
}
|
||||
|
||||
/**
|
||||
* Long context 에서 small 모델이 degenerate 됐을 때의 blast radius 봉쇄:
|
||||
* - 문자 벽 (`_______…`, `~~~~~~`) 8+ 반복은 모두 제거. markdown rule 은 3.
|
||||
* - 영문 (Note: …) 류 자기 narration 제거.
|
||||
* - leaked Chronicle / follow-up scaffolding 제거.
|
||||
* - 중복 paragraph (모델 stutter) 합치기.
|
||||
*
|
||||
* 모델 실패를 *고치는* 게 아니라 "노이즈 벽" 을 "읽을 수 있는 텍스트" 로 유지.
|
||||
*/
|
||||
export function cleanDegeneratedOutput(text: string): string {
|
||||
let s = text;
|
||||
s = s.replace(/([_=~.*])\1{7,}/g, '');
|
||||
s = s.replace(/\(Note:[^)]*\)/gi, '');
|
||||
s = s.replace(/\n?Candidate records for this discussion[^\n]*/gi, '');
|
||||
s = s.replace(/\(\s*질문\s*의도\s*[::][^)]*\)/g, '');
|
||||
s = s.replace(/\[\s*핵심\s*확인\s*질문\s*\]\s*/g, '');
|
||||
const paras = s.split(/\n{2,}/);
|
||||
const deduped: string[] = [];
|
||||
for (const p of paras) {
|
||||
const norm = p.trim().replace(/\s+/g, ' ');
|
||||
const prevNorm = deduped.length ? deduped[deduped.length - 1].trim().replace(/\s+/g, ' ') : '';
|
||||
if (norm && norm === prevNorm) { continue; }
|
||||
deduped.push(p);
|
||||
}
|
||||
return deduped.join('\n\n').replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Continuation round 가 "이어붙이기" 가 아니라 *처음부터 다시* 답변을 시작했는지
|
||||
* detect — 공유 leading prefix 12자 이상 + 양쪽 다 40자 이상이면 restart 로 판정.
|
||||
* 작은 모델이 "continue from here" 지시를 무시하는 흔한 회귀 대응.
|
||||
*/
|
||||
/**
|
||||
* 모델 답변에 `<rationale>…</rationale>` 블록이 포함됐을 때 그 안의
|
||||
* [PROBLEM] / [GOAL] / [REASONING] 세 섹션을 파싱해 구조화된 객체로 반환.
|
||||
* 블록 자체가 없으면 undefined — 호출자가 옵셔널로 처리.
|
||||
*
|
||||
* 섹션 정규식은 `[PROBLEM]…[GOAL]…[REASONING]…` 순서 가정. 어느 한쪽이 빠지면
|
||||
* 해당 필드는 빈 문자열 / fallback (reasoning 은 raw 전체) 로.
|
||||
*/
|
||||
export function parseRationale(text: string): { problem: string; goal: string; reasoning: string } | undefined {
|
||||
const match = text.match(/<rationale>([\s\S]*?)<\/rationale>/);
|
||||
if (!match) return undefined;
|
||||
|
||||
const raw = match[1];
|
||||
const problem = raw.match(/\[PROBLEM\]([\s\S]*?)(?=\[|$)/)?.[1]?.trim() || '';
|
||||
const goal = raw.match(/\[GOAL\]([\s\S]*?)(?=\[|$)/)?.[1]?.trim() || '';
|
||||
const reasoning = raw.match(/\[REASONING\]([\s\S]*?)(?=\[|$)/)?.[1]?.trim() || raw.trim();
|
||||
|
||||
return { problem, goal, reasoning };
|
||||
}
|
||||
|
||||
export function isRestartedAnswer(soFar: string, cont: string): boolean {
|
||||
const norm = (x: string) => x.trim().replace(/\s+/g, ' ').toLowerCase();
|
||||
const a = norm(soFar);
|
||||
const b = norm(cont);
|
||||
if (a.length < 40 || b.length < 40) { return false; }
|
||||
let i = 0;
|
||||
const max = Math.min(a.length, b.length, 80);
|
||||
while (i < max && a[i] === b[i]) { i++; }
|
||||
return i >= 12;
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { ChatMessage } from '../../agent';
|
||||
|
||||
/**
|
||||
* 직전 assistant 답변의 *첫 1-2 문장* (= R1 결론) 을 추출해서 현재 턴의
|
||||
* system prompt 에 [PRIOR TURN CONCLUSION] 블록으로 주입한다.
|
||||
*
|
||||
* 왜 필요한가: 짧은 history (1-2 턴) 이라도 모델은 직전 *결론* 을 자기 추론의
|
||||
* 출발점으로 안 삼는 경우가 잦다 — 사용자가 "X 는 Y 의 개선판이야" 같은
|
||||
* 정정/보강을 던지면 직전 결론 ("ConnectAI 가 더 경험자 코드") 을 *재평가*
|
||||
* 해야 하는데, 그 결론이 무엇이었는지를 명시적으로 다시 보여줘야 모델이
|
||||
* 그것을 anchor 로 잡고 revise 한다. 단순 chat history 로는 모델이
|
||||
* "방금 무엇을 결론지었는지" 를 자기가 추출해야 해서 자주 놓친다.
|
||||
*
|
||||
* 추출 규칙: 마지막 assistant 메시지의 본문에서:
|
||||
* - 첫 비어있지 않은 줄 한 줄 (한국어 문장 종결 "." / "다." / "요." / "음." 등)
|
||||
* - 또는 첫 1-2 문장 (period/question mark 기준)
|
||||
* - 240자 cap
|
||||
*
|
||||
* Returns '' when 첫 턴이거나 직전 assistant 메시지가 없을 때.
|
||||
*/
|
||||
export function buildPriorTurnConclusionContext(history: ChatMessage[]): string {
|
||||
const visible = history.filter(m => !m.internal && (m.role === 'user' || m.role === 'assistant'));
|
||||
const lastAssistant = [...visible].reverse().find(m => m.role === 'assistant');
|
||||
if (!lastAssistant || typeof lastAssistant.content !== 'string') return '';
|
||||
const conclusion = extractConclusion(lastAssistant.content);
|
||||
if (!conclusion) return '';
|
||||
return [
|
||||
'[PRIOR TURN CONCLUSION]',
|
||||
'직전 답변에서 내가 도달한 결론은 다음과 같다:',
|
||||
`> ${conclusion}`,
|
||||
'이번 턴의 사용자 메시지가 이 결론의 전제·근거·범위에 영향을 주는 정보라면, 그 결론을 *재평가* 해서 정정/보강된 결론으로 다시 말해라. 영향이 없다면 왜 없는지 한 줄로 짚고 자연스럽게 다음으로 넘어가라.',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* 본문에서 *결론적인 첫 1-2 문장* 을 뽑는다. Plain-text 모드에서 시스템 프롬프트
|
||||
* R1 이 "첫 문장 = 결론" 을 강제하므로, 첫 문장이 거의 항상 그 턴의 verdict.
|
||||
*
|
||||
* 마크다운 헤더 / 빈 줄 / 백틱 펜스 skip. 240자에서 cut.
|
||||
*/
|
||||
function extractConclusion(text: string): string {
|
||||
// 마크다운 fence + 헤더 + 빈 줄 skip → 첫 의미 있는 줄.
|
||||
const lines = text.split('\n');
|
||||
let firstContent = '';
|
||||
let inFence = false;
|
||||
for (const raw of lines) {
|
||||
const line = raw.trim();
|
||||
if (!line) continue;
|
||||
if (line.startsWith('```')) { inFence = !inFence; continue; }
|
||||
if (inFence) continue;
|
||||
if (/^#+\s/.test(line)) continue; // markdown heading
|
||||
if (line.startsWith('---') || line.startsWith('===')) continue;
|
||||
firstContent = line;
|
||||
break;
|
||||
}
|
||||
if (!firstContent) return '';
|
||||
|
||||
// 첫 1-2 문장 — period / question mark / 한국어 종결 기준.
|
||||
// 250자 안에서 첫 2개 문장 cut, 없으면 250자 cap.
|
||||
const cleaned = firstContent.replace(/\s+/g, ' ').trim();
|
||||
if (cleaned.length <= 240) return cleaned;
|
||||
|
||||
// 마지막 문장 종결 위치 찾기 (240자 이내).
|
||||
const within = cleaned.slice(0, 240);
|
||||
const lastTerminator = Math.max(
|
||||
within.lastIndexOf('. '),
|
||||
within.lastIndexOf('. '),
|
||||
within.lastIndexOf('? '),
|
||||
within.lastIndexOf('! '),
|
||||
within.lastIndexOf('다. '),
|
||||
within.lastIndexOf('요. '),
|
||||
);
|
||||
if (lastTerminator > 60) return within.slice(0, lastTerminator + 1);
|
||||
return within + '…';
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Project-knowledge / local-path 컨텍스트 블록 안에서 *증거 파일* 목록을 뽑아내는
|
||||
* pure parser 모음. 두 종류 컨텍스트 포맷이 살짝 달라 함수를 둘로 둠:
|
||||
*
|
||||
* - extractEvidenceFilesFromProjectKnowledge: `## Evidence Files` 블록 (project
|
||||
* knowledge overview 문서 포맷). 없으면 `## Confirmed Structure` 블록에서
|
||||
* backtick 경로 fallback.
|
||||
* - extractPriorityPreviewFiles: `File:` 헤더 (local path scan 결과 포맷).
|
||||
* 없으면 `Priority file previews:` 섹션의 `### file/path` 헤더 fallback.
|
||||
*
|
||||
* 둘 다 stateless 정규식 파싱이라 god-file 에 박혀 있을 이유 없음. dedupe 까지
|
||||
* 함수 안에서 처리해 호출자는 그냥 list 받아쓰면 된다.
|
||||
*/
|
||||
|
||||
export function extractEvidenceFilesFromProjectKnowledge(recentProjectKnowledgeContext: string): string[] {
|
||||
const evidenceBlock = recentProjectKnowledgeContext.match(/## Evidence Files\n([\s\S]*?)(?=\n## |\n# |$)/i)?.[1] || '';
|
||||
const evidenceFiles = [...evidenceBlock.matchAll(/-\s+`([^`]+)`/g)].map((match) => match[1].trim());
|
||||
if (evidenceFiles.length > 0) {
|
||||
return Array.from(new Set(evidenceFiles));
|
||||
}
|
||||
|
||||
const structureBlock = recentProjectKnowledgeContext.match(/## Confirmed Structure\n([\s\S]*?)(?=\n## |\n# |$)/i)?.[1] || '';
|
||||
return Array.from(new Set([...structureBlock.matchAll(/`([^`]+)`/g)]
|
||||
.map((match) => match[1].trim())
|
||||
.filter((value) => /[\\/]/.test(value) || /\.[a-z0-9]+$/i.test(value))));
|
||||
}
|
||||
|
||||
export function extractPriorityPreviewFiles(localPathContext: string): string[] {
|
||||
const fileMarkerMatches = [...localPathContext.matchAll(/^File:\s*(.+)$/gmi)]
|
||||
.map((match) => match[1].trim());
|
||||
if (fileMarkerMatches.length > 0) {
|
||||
return Array.from(new Set(fileMarkerMatches));
|
||||
}
|
||||
|
||||
const previewBlock = localPathContext.match(/Priority file previews:\n([\s\S]*)/)?.[1] || '';
|
||||
return Array.from(new Set([...previewBlock.matchAll(/^###\s+(.+)$/gmi)]
|
||||
.map((match) => match[1].trim())
|
||||
.filter((value) => /[\\/]/.test(value) || /\.[a-z0-9]+$/i.test(value))));
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { logError } from '../../utils';
|
||||
import { extractPriorityPreviewFiles } from './projectEvidence';
|
||||
|
||||
/**
|
||||
* "프로젝트 지식 (project knowledge overview)" 문서 빌드 + 디스크 쓰기.
|
||||
*
|
||||
* 흐름:
|
||||
* 1) buildProjectKnowledgeMarkdown — localPathContext 를 받아 표준화된 Markdown
|
||||
* overview 본문 생성 (pure).
|
||||
* 2) writeProjectKnowledgeRecord — 그 본문을 `docs/records/<project>/development/`
|
||||
* 아래에 일자/슬러그 파일명으로 저장.
|
||||
* 3) buildProjectKnowledgeFallbackAnswer — 모델이 "추가 정보 필요" 류 회피 답변을
|
||||
* 낼 때 덮어씌울 채팅용 답변 (markdown 초안 포함).
|
||||
*
|
||||
* Why one module: 셋 다 동일 helper (`getProjectDisplayName`, `extractPriority...`,
|
||||
* Path 파싱) 를 공유하고, 동일 사용 흐름 (knowledge 작성 turn) 의 일부라서 묶어
|
||||
* 두는 게 god-file 다이어트 + 응집도 모두에 유리.
|
||||
*
|
||||
* Note: writeProjectKnowledgeRecord 는 fs I/O 가 들어가지만 transactionManager
|
||||
* 가 필요 없는 *append-only 신규 파일 생성* 경로 (덮어쓸 가능성 거의 0). 그래서
|
||||
* 별도 transactional 처리 없이 pure-ish 모듈에 함께 둠.
|
||||
*/
|
||||
|
||||
/** `ConnectAI` 디렉토리는 사용자가 `Astra` 로 부르므로 표시명 alias. 그 외는 그대로. */
|
||||
export function getProjectDisplayName(projectPath: string): string {
|
||||
const projectName = path.basename(projectPath);
|
||||
return /^connectai$/i.test(projectName) ? 'Astra' : projectName;
|
||||
}
|
||||
|
||||
/** localPathContext 의 `Path:` 라인 + tree + priority files 를 표준 markdown 으로 직렬화. */
|
||||
export function buildProjectKnowledgeMarkdown(localPathContext: string): string {
|
||||
const pathMatch = localPathContext.match(/Path:\s*(.+)/);
|
||||
const projectPath = pathMatch?.[1]?.trim() || 'Unknown project path';
|
||||
const projectDisplayName = getProjectDisplayName(projectPath);
|
||||
const treeMatch = localPathContext.match(/Scanned tree:\n([\s\S]*?)(?:\nPriority file previews:|$)/);
|
||||
const treePreview = treeMatch?.[1]?.trim().split('\n').slice(0, 80).join('\n') || '';
|
||||
const priorityFiles = extractPriorityPreviewFiles(localPathContext);
|
||||
|
||||
return [
|
||||
`# ${projectDisplayName} Project Knowledge Overview`,
|
||||
'',
|
||||
`Date: ${new Date().toISOString()}`,
|
||||
`Project: ${projectDisplayName}`,
|
||||
`Repository: \`${projectPath}\``,
|
||||
'',
|
||||
'## Purpose',
|
||||
`${projectDisplayName}는 VS Code 안에서 로컬 AI 에이전트, Second Brain, 프로젝트 기록, 에이전트 스킬을 연결하는 개발 보조 프로젝트다.`,
|
||||
'',
|
||||
'## Confirmed Structure',
|
||||
'- `src/agent.ts`: 에이전트 실행, 로컬 경로 프리플라이트, Second Brain Trace, 액션 실행 흐름의 중심.',
|
||||
'- `src/sidebarProvider.ts`: Webview UI, 브레인/모델/프로젝트 선택, 프롬프트 전달, 기록 UI를 담당.',
|
||||
'- `src/features/secondBrainTrace.ts`: Second Brain 검색 결과와 근거 정책을 구성.',
|
||||
'- `src/features/projectChronicle/`: 프로젝트 기록을 Markdown으로 관리하는 Chronicle 기능.',
|
||||
'- `src/core/`: 큐, 이벤트, 트랜잭션, 오류 처리 등 실행 안정성 계층.',
|
||||
'- `tests/`: Second Brain, 로컬 경로 프리플라이트, Chronicle, 보안/트랜잭션 회귀 테스트.',
|
||||
'',
|
||||
'## Evidence Files',
|
||||
...(priorityFiles.length ? priorityFiles.map((file) => `- \`${file}\``) : ['- 확인된 우선 파일 없음']),
|
||||
'',
|
||||
'## Scanned Tree Excerpt',
|
||||
'```text',
|
||||
treePreview || '(no scanned tree captured)',
|
||||
'```',
|
||||
'',
|
||||
'## Current Knowledge Gap',
|
||||
'- 전체 아키텍처는 파일 구조와 일부 프리뷰 기준으로 파악 가능하지만, 세부 동작 지식은 `src/agent.ts`, `src/sidebarProvider.ts`, `secondBrainTrace.ts`, `projectChronicle` 순서로 심화 분석해 보강해야 한다.',
|
||||
'',
|
||||
'## Next Records',
|
||||
'- `agent.ts` 실행 흐름 상세 분석',
|
||||
'- Second Brain Trace 검색 및 근거 정책 분석',
|
||||
'- Project Chronicle 기록 생성 흐름 분석',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* 프로젝트 지식 markdown 을 `docs/records/<project>/development/<date>_<slug>_…md`
|
||||
* 에 저장. Access 안 됐거나 projectPath 추출 실패 시 null. 쓰기 실패는 log 만
|
||||
* 남기고 null 반환 (호출자가 fallback path 로 떨어지게).
|
||||
*/
|
||||
export function writeProjectKnowledgeRecord(
|
||||
localPathContext: string,
|
||||
): { filePath: string; relativePath: string } | null {
|
||||
const pathMatch = localPathContext.match(/Path:\s*(.+)/);
|
||||
const projectPath = pathMatch?.[1]?.trim();
|
||||
if (!projectPath || !localPathContext.includes('Access: succeeded')) return null;
|
||||
|
||||
try {
|
||||
const projectName = path.basename(projectPath);
|
||||
const projectDisplayName = getProjectDisplayName(projectPath);
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const slug = projectDisplayName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'project';
|
||||
const relativePath = path.join('docs', 'records', projectName, 'development', `${today}_${slug}_project_knowledge_overview.md`);
|
||||
const filePath = path.join(projectPath, relativePath);
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, buildProjectKnowledgeMarkdown(localPathContext), 'utf8');
|
||||
return { filePath, relativePath };
|
||||
} catch (error: any) {
|
||||
logError('Failed to write project knowledge record.', { error: error?.message || String(error) });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 모델이 "추가 정보 필요" 류 회피 답변을 낼 때 덮어씌울 채팅용 답변. 사전
|
||||
* 작성된 한국어 템플릿 안에 priorityFiles / treePreview / record 경로 만 동적으로
|
||||
* 채워 넣는다.
|
||||
*/
|
||||
export function buildProjectKnowledgeFallbackAnswer(
|
||||
localPathContext: string,
|
||||
record?: { filePath: string; relativePath: string } | null,
|
||||
): string {
|
||||
const pathMatch = localPathContext.match(/Path:\s*(.+)/);
|
||||
const projectPath = pathMatch?.[1]?.trim() || '제공된 로컬 프로젝트 경로';
|
||||
const projectDisplayName = getProjectDisplayName(projectPath);
|
||||
const treeMatch = localPathContext.match(/Scanned tree:\n([\s\S]*?)(?:\nPriority file previews:|$)/);
|
||||
const treePreview = treeMatch?.[1]?.trim().split('\n').slice(0, 18).join('\n') || '';
|
||||
const priorityMatches = extractPriorityPreviewFiles(localPathContext).slice(0, 10);
|
||||
const priorityText = priorityMatches.length
|
||||
? priorityMatches.map((file) => `- ${file}`).join('\n')
|
||||
: '- package.json, src, docs, config 계열 파일을 우선 확인';
|
||||
|
||||
return [
|
||||
'## 간단 요약',
|
||||
'맞아요. 이 경우에는 추가 질문으로 멈출 필요 없이, 지금 확인된 로컬 프로젝트 구조를 기준으로 기본 프로젝트 지식을 바로 만들면 됩니다.',
|
||||
'',
|
||||
'## 기본 지식 생성 방향',
|
||||
`대상 프로젝트는 \`${projectPath}\`입니다. 우선 MVP 지식은 “프로젝트 개요 + 주요 모듈 + 확인된 근거 파일 + 다음에 깊게 볼 영역” 형태로 만드는 것이 가장 안전합니다.`,
|
||||
'',
|
||||
'## 확인된 근거',
|
||||
priorityText,
|
||||
'',
|
||||
treePreview ? `## 확인된 구조 일부\n\`\`\`text\n${treePreview}\n\`\`\`` : '',
|
||||
'',
|
||||
'## 바로 만들 지식 초안',
|
||||
'```markdown',
|
||||
`# ${projectDisplayName} Project Knowledge Overview`,
|
||||
'',
|
||||
'## Purpose',
|
||||
`${projectDisplayName}는 VS Code 안에서 로컬 AI 에이전트, Second Brain, 프로젝트 기록, 에이전트 스킬을 연결하는 개발 보조 프로젝트다.`,
|
||||
'',
|
||||
'## Confirmed Structure',
|
||||
'- `src/agent.ts`: 에이전트 실행, 로컬 경로 프리플라이트, Second Brain Trace, 액션 실행 흐름의 중심.',
|
||||
'- `src/sidebarProvider.ts`: Webview UI, 브레인/모델/프로젝트 선택, 프롬프트 전달, 기록 UI를 담당.',
|
||||
'- `src/features/secondBrainTrace.ts`: Second Brain 검색 결과와 근거 정책을 구성.',
|
||||
'- `src/features/projectChronicle/`: 프로젝트 기록을 Markdown으로 관리하는 Chronicle 기능.',
|
||||
'- `src/core/`: 큐, 이벤트, 트랜잭션, 오류 처리 등 실행 안정성 계층.',
|
||||
'- `tests/`: Second Brain, 로컬 경로 프리플라이트, Chronicle, 보안/트랜잭션 회귀 테스트.',
|
||||
'',
|
||||
'## Current Knowledge Gap',
|
||||
'- 전체 아키텍처는 파일 구조와 일부 프리뷰 기준으로 파악 가능하지만, 세부 동작 지식은 `src/agent.ts`, `src/sidebarProvider.ts`, `secondBrainTrace.ts`, `projectChronicle` 순서로 심화 분석해 보강해야 한다.',
|
||||
'',
|
||||
'## Recommended Next Record',
|
||||
`- \`docs/records/${path.basename(projectPath)}/development/YYYY-MM-DD_${projectDisplayName.toLowerCase()}_project_knowledge_overview.md\``,
|
||||
'```',
|
||||
'',
|
||||
'## 다음 액션',
|
||||
record
|
||||
? `프로젝트 지식 1번 문서를 생성했습니다: \`${record.filePath}\``
|
||||
: '기본값으로는 위 초안을 프로젝트 지식 1번 문서로 저장하고, 그 다음 `agent.ts` 실행 흐름 지식을 별도 문서로 쪼개는 것이 좋습니다.',
|
||||
].filter(Boolean).join('\n');
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* 사용자 prompt 의 *의도 분류* 류 detection helpers. 모두 stateless 정규식 매칭.
|
||||
*
|
||||
* 옛 코드는 agent.ts 의 private 메서드로 박혀 있었는데, system prompt 빌더
|
||||
* (`buildJarvisProjectBriefContext` 등) 가 이걸 의존하면서 god-file 안에서 서로
|
||||
* 얽힘. 헬퍼만 먼저 떼면 의존 그래프가 단방향 (builders → detection) 으로 정리됨.
|
||||
*
|
||||
* 주의: 패턴 확장 시 false positive 폭 넓혀 일반 질문에서도 thinking-partner
|
||||
* 컨텍스트가 잘못 박히지 않는지 검증할 것 — 잘못된 컨텍스트는 모델 출력 품질을
|
||||
* 직접 깎는다.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Thinking partner request — 사용자가 "어떻게 생각해?" "어떤 게 맞을까?" 등
|
||||
* *결정 / 의견 / 판단 / 구조* 류 메타 질문을 던졌는가. 한 줄 정규식으로 폭 넓게
|
||||
* 잡되, 단어 일부 매칭이라 "구조" 같은 흔한 단어가 들어가면 true 일 수 있음 —
|
||||
* 호출자는 이걸 다른 detection (`isCasualConversationPrompt`) 과 함께 써야 함.
|
||||
*/
|
||||
export function isThinkingPartnerRequest(prompt: string): boolean {
|
||||
return /(어떤\s*거?\s*같|어때|어떻게\s*생각|의견|판단|방향|설계|아키텍처|구조|자비스|생각.*정리|갈림길|architecture|design|direction|opinion|think|judge)/i.test(prompt);
|
||||
}
|
||||
|
||||
/**
|
||||
* Standalone greetings / acknowledgements / fillers — *전체 메시지* 가 짧은 잡담
|
||||
* 한 마디일 때만 true. "안녕, 이 프로젝트 분석해줘" 같이 일감이 붙으면 길어져
|
||||
* 자동 탈락. 잘못 잡으면 단순 "안녕" 에도 RAG 가 돌아 옛 프로젝트 기록을 끌어와
|
||||
* 모델이 stale context 에 답하는 사고가 남.
|
||||
*/
|
||||
const CASUAL_PHRASES = new Set<string>([
|
||||
// greetings
|
||||
'안녕', '안녕하세요', '안녕하십니까', '안뇽', '하이', '하잉', '헬로', '헬로우', 'hello', 'hi', 'hii', 'hey', 'yo', 'ㅎㅇ', 'ㅎㅇㅎㅇ', '굿모닝', 'good morning', 'morning', 'gm',
|
||||
// farewells
|
||||
'잘가', '잘가요', '안녕히', '안녕히가세요', '안녕히계세요', '바이', '바이바이', 'bye', 'byebye', 'bye bye', 'goodbye', 'good bye', '굿바이', '잘자', '잘자요', '굿나잇', 'good night', 'gn',
|
||||
// acknowledgements / affirmations
|
||||
'네', '넵', '넹', '예', '응', '웅', '음', '흠', '엄', '그래', '그렇구나', '그렇군', '그렇네', '오케이', '오케', 'ok', 'okay', 'okey', 'k', 'ㅇㅋ', 'ㅇㅇ', '알겠어', '알겠습니다', '알겠어요', '알았어', '알았다', '알았어요', 'yes', 'yeah', 'yep', 'yup', 'sure', '좋아', '좋아요', '좋네', '좋다', 'good', 'fine',
|
||||
// negations (still small talk — needs no RAG; the prior turn is already in the chat history)
|
||||
'아니', '아니요', '아니오', 'ㄴㄴ', 'no', 'nope', 'nah',
|
||||
// thanks / praise
|
||||
'고마워', '고마워요', '고맙습니다', '감사', '감사해요', '감사합니다', 'thanks', 'thank you', 'thx', 'ty', '굿', '굳', '굿잡', 'good job', '잘했어', '잘했네', '훌륭', '훌륭해', '대박', 'nice', 'cool', 'great', 'awesome', 'perfect', '완벽', '수고', '수고했어', '수고하셨습니다', '고생했어', '고생많았어',
|
||||
// laughs / fillers
|
||||
'lol', 'haha', 'hmm', 'hmmm', 'umm', 'uh',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Second Brain 명시 요청 — RAG 강제 발동 트리거. 키워드 폭이 넓어서 false positive
|
||||
* 가능하나 호출자가 force=true 의도로만 사용하므로 약간 관대해도 OK.
|
||||
*/
|
||||
export function isExplicitSecondBrainRequest(prompt: string): boolean {
|
||||
return /(second brain|2nd brain|제2뇌|브레인|brain|기억|기록|노트|문서|참고해서|사용해서|검색해서|근거|출처)/i.test(prompt);
|
||||
}
|
||||
|
||||
/**
|
||||
* Second Brain *전체 평가* 요청 — "내 제2뇌의 강점/약점은?" 류. brain 키워드 +
|
||||
* overview/평가 키워드 둘 다 있어야 true (좁게 잡음). 매치되면 inventory 컨텍스트
|
||||
* 블록 + fallback 답변 path 가 활성화.
|
||||
*/
|
||||
export function isSecondBrainInventoryRequest(prompt: string): boolean {
|
||||
const normalized = prompt.toLowerCase();
|
||||
const asksBrain = /(second brain|2nd brain|제2뇌|브레인|brain)/i.test(normalized);
|
||||
const asksOverview = /(평가|분석|강점|약점|부족|무엇을 할 수|활용|전체|연결된|현재|inside|overview|inventory|strength|weakness)/i.test(normalized);
|
||||
return asksBrain && asksOverview;
|
||||
}
|
||||
|
||||
/**
|
||||
* 모델이 "데이터/파일/근거 없어서 평가 불가" 류 회피 답변을 냈는지 detect. true 면
|
||||
* 호출자(inventory 흐름) 가 답변을 fallback 본문으로 덮어쓴다.
|
||||
*/
|
||||
export function isNoBrainDataRefusal(answer: string): boolean {
|
||||
return /(분석할 만한 실제 데이터가 없어|분석할.*데이터가 없어|파일 목록.*제공|핵심 내용.*제공|자료를 준비|지식을 먼저 제공|cannot be evaluated|no data|no files)/i.test(answer);
|
||||
}
|
||||
|
||||
export function isCasualConversationPrompt(prompt: string): boolean {
|
||||
const normalized = (prompt || '')
|
||||
.trim()
|
||||
.replace(/\s+/g, ' ')
|
||||
.replace(/[~!?.,,。!?·…\s]+$/g, '')
|
||||
.toLowerCase();
|
||||
if (!normalized) return false;
|
||||
if (normalized.length > 40) return false;
|
||||
|
||||
if (CASUAL_PHRASES.has(normalized)) return true;
|
||||
if (/^[ㅋㅎ]{2,}$/.test(normalized)) return true; // ㅋㅋ, ㅎㅎㅎ, ㅋㅎㅋㅎ
|
||||
if (/^(?:ha){2,}h?$|^(?:he){2,}h?$/.test(normalized)) return true; // haha, hahaha, hehe
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { logError, summarizeText } from '../../utils';
|
||||
import type { ChatMessage } from '../../agent';
|
||||
import { extractEvidenceFilesFromProjectKnowledge, extractPriorityPreviewFiles } from './projectEvidence';
|
||||
|
||||
/**
|
||||
* "이전 라운드에서 만든 project knowledge overview 문서" 를 *이번 답변의 근거* 로
|
||||
* 끌어오는 follow-up 흐름 + 답변 사후 근거 섹션 주입.
|
||||
*
|
||||
* 흐름:
|
||||
* 1) isProjectKnowledgeFollowupRequest — prompt 가 architecture/structure 류
|
||||
* 후속 질문인지 detect.
|
||||
* 2) findRecentProjectKnowledgeRecord — chat history 에서 옛 record path 추출
|
||||
* → fallback 으로 `docs/records/**` 의 가장 최근 *_project_knowledge_overview.md.
|
||||
* 3) buildRecentProjectKnowledgeContext — record 본문을 5000자로 잘라 system prompt 에
|
||||
* prepend.
|
||||
* 4) ensureRecentProjectKnowledgeEvidence / ensureLocalProjectPathEvidence — 모델
|
||||
* 답변에 "## 근거" 섹션이 없으면 끝에 자동 추가 (사용자가 근거 출처를 즉시
|
||||
* 확인할 수 있게).
|
||||
* 5) isBlockingProjectKnowledgeAnswer — 모델이 "방향 알려주세요" 류 blocking 답변을
|
||||
* 낸 경우 detect (호출자가 fallback path 로 우회).
|
||||
*
|
||||
* 모두 stateless — agent.ts 의 private 메서드를 그대로 추출. findRecord 만 옛
|
||||
* 버전이 `this.chatHistory` 를 직접 읽었는데 history 를 명시 arg 로 받게 변경.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Follow-up 질문 detection — architecture / structure / design 류 키워드가 한 개라도
|
||||
* 있으면 true. 옛 record 가 있을 때만 의미 있는 detection 이므로 호출자는 보통
|
||||
* `rootPath` 도 함께 체크.
|
||||
*/
|
||||
export function isProjectKnowledgeFollowupRequest(prompt: string): boolean {
|
||||
return /(아키텍처|구조|조사|분석|설계|흐름|모듈|역할|개선|architecture|structure|design|flow|module|investigate|analy[sz]e)/i.test(prompt);
|
||||
}
|
||||
|
||||
/** "project evidence:" 라인에서 옛 record path 만 뽑아냄. 없으면 null. */
|
||||
export function extractRecentProjectKnowledgeRecordPath(recentProjectKnowledgeContext: string): string | null {
|
||||
return recentProjectKnowledgeContext.match(/project evidence:\s*(\/Volumes\/Data\/project\/Antigravity\/[^\s`"'<>]+\.md)/i)?.[1] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Chat history → 옛 record path 추출 (가장 최근부터). history 에 없으면 fallback 으로
|
||||
* `docs/records/**` 를 직접 scan 해 mtime 기준 가장 최근 `*_project_knowledge_overview.md`
|
||||
* 를 반환. 둘 다 실패면 null.
|
||||
*/
|
||||
export function findRecentProjectKnowledgeRecord(history: ChatMessage[], rootPath: string): string | null {
|
||||
const fromHistory = [...history]
|
||||
.reverse()
|
||||
.map((message) => typeof message.content === 'string'
|
||||
? message.content.match(/\/Volumes\/Data\/project\/Antigravity\/[^\s`"'<>]+_project_knowledge_overview\.md/i)?.[0]
|
||||
: undefined)
|
||||
.find(Boolean);
|
||||
if (fromHistory && fs.existsSync(fromHistory)) {
|
||||
return fromHistory;
|
||||
}
|
||||
|
||||
const recordsRoot = path.join(rootPath, 'docs', 'records');
|
||||
if (!fs.existsSync(recordsRoot)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const candidates: string[] = [];
|
||||
const visit = (dir: string, depth: number) => {
|
||||
if (depth > 5) return;
|
||||
let entries: fs.Dirent[] = [];
|
||||
try {
|
||||
entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
if (!entry.name.startsWith('.')) visit(fullPath, depth + 1);
|
||||
continue;
|
||||
}
|
||||
if (/_project_knowledge_overview\.md$/i.test(entry.name)) {
|
||||
candidates.push(fullPath);
|
||||
}
|
||||
}
|
||||
};
|
||||
visit(recordsRoot, 0);
|
||||
|
||||
return candidates
|
||||
.filter((file) => fs.existsSync(file))
|
||||
.sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs)[0] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Follow-up 감지 시 옛 record 본문을 system prompt 에 prepend. 감지 안 되거나
|
||||
* record 가 없으면 빈 문자열. Content 는 5000자 cap (token 폭증 방지).
|
||||
*/
|
||||
export function buildRecentProjectKnowledgeContext(
|
||||
prompt: string,
|
||||
rootPath: string,
|
||||
history: ChatMessage[],
|
||||
): string {
|
||||
if (!rootPath || !isProjectKnowledgeFollowupRequest(prompt)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const recordPath = findRecentProjectKnowledgeRecord(history, rootPath);
|
||||
if (!recordPath) {
|
||||
return '';
|
||||
}
|
||||
|
||||
try {
|
||||
const content = fs.readFileSync(recordPath, 'utf8');
|
||||
return [
|
||||
'[RECENT LOCAL PROJECT KNOWLEDGE]',
|
||||
'The current user request appears to continue a previous local project knowledge discussion.',
|
||||
`Use this recently generated project knowledge record as project evidence: ${recordPath}`,
|
||||
'When answering, explicitly say that the analysis is based on the recently generated project knowledge record and local project structure. Do not imply that Second Brain Trace was the only evidence.',
|
||||
'If deeper architecture detail is needed, recommend reading the concrete source files next instead of asking for the project path again.',
|
||||
'',
|
||||
summarizeText(content, 5000),
|
||||
].join('\n');
|
||||
} catch (error: any) {
|
||||
logError('Failed to load recent project knowledge record.', { recordPath, error: error?.message || String(error) });
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 모델 답변에 record path 가 안 보이면 끝에 "## 근거" 섹션을 자동 추가. 이미
|
||||
* record path 가 포함돼 있으면 noop (중복 방지).
|
||||
*/
|
||||
export function ensureRecentProjectKnowledgeEvidence(content: string, recentProjectKnowledgeContext: string): string {
|
||||
const recordPath = extractRecentProjectKnowledgeRecordPath(recentProjectKnowledgeContext);
|
||||
if (!recordPath || content.includes(recordPath)) {
|
||||
return content;
|
||||
}
|
||||
|
||||
const evidenceFiles = extractEvidenceFilesFromProjectKnowledge(recentProjectKnowledgeContext).slice(0, 8);
|
||||
const evidenceSection = [
|
||||
'## 근거',
|
||||
`이번 답변은 최근 생성된 프로젝트 지식 기록과 로컬 프로젝트 구조를 기준으로 작성했습니다: \`${recordPath}\``,
|
||||
evidenceFiles.length
|
||||
? `확인된 근거 파일:\n${evidenceFiles.map((file) => `- \`${file}\``).join('\n')}`
|
||||
: '',
|
||||
].filter(Boolean).join('\n\n');
|
||||
|
||||
return [
|
||||
content.trim(),
|
||||
'',
|
||||
evidenceSection,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* 모델 답변에 "## 근거" 가 없고 local path access 가 성공한 경우, 끝에 path
|
||||
* evidence 섹션을 자동 추가. recent record 경로가 아니라 *방금 inspect 한 로컬
|
||||
* 프로젝트 경로* 가 근거.
|
||||
*/
|
||||
export function ensureLocalProjectPathEvidence(content: string, localPathContext: string): string {
|
||||
if (!localPathContext.includes('Access: succeeded') || content.includes('## 근거')) {
|
||||
return content;
|
||||
}
|
||||
|
||||
const pathMatch = localPathContext.match(/Path:\s*(.+)/);
|
||||
const projectPath = pathMatch?.[1]?.trim();
|
||||
const evidenceFiles = extractPriorityPreviewFiles(localPathContext).slice(0, 10);
|
||||
if (!projectPath && evidenceFiles.length === 0) {
|
||||
return content;
|
||||
}
|
||||
|
||||
const evidenceSection = [
|
||||
'## 근거',
|
||||
projectPath
|
||||
? `이번 답변은 로컬 프로젝트 경로 \`${projectPath}\`에서 확인한 파일 구조와 코드 프리뷰를 기준으로 작성했습니다.`
|
||||
: '이번 답변은 확인된 로컬 프로젝트 파일 구조와 코드 프리뷰를 기준으로 작성했습니다.',
|
||||
evidenceFiles.length
|
||||
? `확인된 근거 파일:\n${evidenceFiles.map((file) => `- \`${file}\``).join('\n')}`
|
||||
: '',
|
||||
].filter(Boolean).join('\n\n');
|
||||
|
||||
return [
|
||||
content.trim(),
|
||||
'',
|
||||
evidenceSection,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* 모델이 "방향 알려주세요 / 어떤 부분 먼저 / 구체적인 방향 필요" 같은 blocking 답변을
|
||||
* 만들었는지 detect. 호출자는 true 일 때 fallback path 로 우회해 자동 진행.
|
||||
*/
|
||||
export function isBlockingProjectKnowledgeAnswer(content: string): boolean {
|
||||
return /(블로킹 질문|어떤 기능 영역|어떤 부분.*먼저|어떤 기능이나 아키텍처|구체적인 방향|방향 설정이 필요|명확히 알려주시면|우선적으로 정리|최종 사용 목적|Question reason|별도의 파일 기록.*생성되지|파일 기록이 생성되지|더 깊이 있는 분석.*지정|해당 기능.*지정하여 요청)/i.test(content);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import * as path from 'path';
|
||||
import type { BrainProfile } from '../../config';
|
||||
import type { SecondBrainTrace } from '../../features/secondBrainTrace';
|
||||
|
||||
/**
|
||||
* Second Brain *전체* 평가 요청 ("내 제2뇌 분석해줘", "강점/약점은?") 류 prompt 가
|
||||
* 들어왔을 때 모델이 "데이터가 없어서 평가 불가" 같은 회피성 답변을 못 하도록
|
||||
* 시스템 prompt 에 prepend 하는 inventory 블록 + 마지막 방어선 fallback 답변.
|
||||
*
|
||||
* 두 함수 다 stateless — agent.ts 의 private 메서드를 그대로 추출.
|
||||
*
|
||||
* Why fallback 까지 필요한가: inventory 컨텍스트를 박아도 모델이 가끔 "분석할
|
||||
* 데이터가 없습니다" 류 답변을 만드는 회귀가 있었고 (`isNoBrainDataRefusal`
|
||||
* detection), 그 경우엔 답변 자체를 이 fallback 으로 덮어쓴다.
|
||||
*/
|
||||
|
||||
/** 시스템 prompt 에 prepend 할 inventory 컨텍스트 블록. */
|
||||
export function buildSecondBrainInventoryContext(activeBrain: BrainProfile, brainFiles: string[]): string {
|
||||
const relativeFiles = brainFiles.map((file) => path.relative(activeBrain.localBrainPath, file));
|
||||
const directoryCounts = new Map<string, number>();
|
||||
for (const rel of relativeFiles) {
|
||||
const topDir = rel.includes(path.sep) ? rel.split(path.sep)[0] : '(root)';
|
||||
directoryCounts.set(topDir, (directoryCounts.get(topDir) || 0) + 1);
|
||||
}
|
||||
|
||||
const topDirectories = [...directoryCounts.entries()]
|
||||
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
|
||||
.slice(0, 12)
|
||||
.map(([dir, count]) => `- ${dir}: ${count} markdown files`)
|
||||
.join('\n');
|
||||
|
||||
const samples = relativeFiles
|
||||
.slice(0, 40)
|
||||
.map((file) => `- ${file}`)
|
||||
.join('\n');
|
||||
|
||||
return [
|
||||
'[SECOND BRAIN INVENTORY]',
|
||||
'The user is asking about the currently selected Second Brain as a knowledge base. Use this inventory as direct evidence.',
|
||||
`Selected brain name: ${activeBrain.name}`,
|
||||
`Selected brain path: ${activeBrain.localBrainPath}`,
|
||||
`Markdown file count: ${brainFiles.length}`,
|
||||
brainFiles.length > 0
|
||||
? 'Do not say the Second Brain has no data, no files, or cannot be evaluated because files were not provided.'
|
||||
: 'No Markdown files were found in the selected Second Brain path.',
|
||||
topDirectories ? `Top-level distribution:\n${topDirectories}` : 'Top-level distribution: none',
|
||||
samples ? `Sample files:\n${samples}` : 'Sample files: none',
|
||||
'For strengths and weaknesses, infer from the inventory and selected note excerpts. Mark broad conclusions as inference when they are not directly proven.',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* 모델이 회피성 답변을 만들었을 때 덮어씌울 마지막 방어선 답변. 강점/약점/분포/
|
||||
* 활용 가능성을 사전 작성된 한국어 템플릿으로 채우되, 분포와 trace 결과만
|
||||
* inventory 데이터로 동적으로 메운다.
|
||||
*/
|
||||
export function buildSecondBrainInventoryFallbackAnswer(
|
||||
activeBrain: BrainProfile,
|
||||
brainFiles: string[],
|
||||
trace: SecondBrainTrace | null,
|
||||
): string {
|
||||
const relativeFiles = brainFiles.map((file) => path.relative(activeBrain.localBrainPath, file));
|
||||
const directoryCounts = new Map<string, number>();
|
||||
for (const rel of relativeFiles) {
|
||||
const topDir = rel.includes(path.sep) ? rel.split(path.sep)[0] : '(root)';
|
||||
directoryCounts.set(topDir, (directoryCounts.get(topDir) || 0) + 1);
|
||||
}
|
||||
|
||||
const topDirectories = [...directoryCounts.entries()]
|
||||
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
|
||||
.slice(0, 8);
|
||||
const distribution = topDirectories
|
||||
.map(([dir, count]) => `- ${dir}: ${count}개`)
|
||||
.join('\n');
|
||||
const selectedDocs = trace?.retrievedDocuments
|
||||
.filter((doc) => doc.selectedForAnswerContext)
|
||||
.map((doc) => `- ${doc.path} (${doc.sourceType}, score ${doc.score})`)
|
||||
.join('\n') || '';
|
||||
|
||||
return [
|
||||
'## 간단 요약',
|
||||
`현재 선택된 제2뇌는 비어 있지 않습니다. \`${activeBrain.localBrainPath}\` 아래에서 Markdown 파일 ${brainFiles.length}개를 확인했기 때문에, 강점과 약점을 평가할 수 있습니다.`,
|
||||
'',
|
||||
'## 강점',
|
||||
'1. 지식량이 충분합니다. 수천 개 규모의 Markdown 노트가 있어 단일 프로젝트 메모장이 아니라 실제 지식 베이스로 볼 수 있습니다.',
|
||||
'2. 상위 폴더 기준으로 주제가 나뉘어 있어 검색과 확장에 유리합니다.',
|
||||
'3. AI, UX, 프로젝트 로그처럼 실행 지식과 참고 지식이 함께 있어 기획, 리서치, 의사결정 보조에 쓸 수 있습니다.',
|
||||
'4. Trace가 실제 문서를 찾고 있으므로 연결 자체는 동작합니다.',
|
||||
'',
|
||||
'## 약점',
|
||||
'1. 검색 결과에서 인덱스 문서와 일반 지식 문서가 상위에 올라옵니다. 제2뇌 전체 평가에는 도움이 되지만, 구체적 판단 근거로는 밀도가 낮습니다.',
|
||||
'2. Project Evidence와 General Knowledge가 명확히 분리되지 않아 답변이 조심스러워집니다.',
|
||||
'3. “강점/약점 평가” 같은 전체 분석 요청에는 단일 키워드 검색보다 폴더 분포, 대표 문서, 최근 문서, 프로젝트 로그를 함께 보는 전용 분석 흐름이 필요합니다.',
|
||||
'4. 문서 수가 많아서 요약 인덱스, 태그, source type 메타데이터가 약하면 좋은 문서가 검색 순위에서 밀릴 수 있습니다.',
|
||||
'',
|
||||
'## 확인된 분포',
|
||||
distribution || '- 상위 폴더 없음',
|
||||
'',
|
||||
selectedDocs ? '## 이번 검색에서 잡힌 문서\n' + selectedDocs : '',
|
||||
'',
|
||||
'## 활용 가능성',
|
||||
'이 제2뇌는 프로젝트 회고, UX/비즈니스 판단, 기술 리서치, 제안서 초안, 의사결정 근거 정리, 고객 요구사항 검토에 쓸 수 있습니다. 다음 개선 포인트는 “인덱스 문서보다 실제 근거 문서를 우선 선택하는 검색 랭킹”과 “프로젝트 근거 문서에 명시적 메타데이터를 붙이는 것”입니다.',
|
||||
].filter(Boolean).join('\n');
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* System prompt 의 *조립 단계* 에서 일어나는 두 가지 stateless 변환:
|
||||
*
|
||||
* 1) stripAstraFormattingForAgentMode — Agent Mode v3 에서 Astra 기본 시스템
|
||||
* 프롬프트의 *포맷/페르소나/스탠스* 섹션을 제거. 에이전트 자체 프롬프트와
|
||||
* 섹션 헤더가 충돌해 모델이 두 톤을 섞어 답하는 회귀를 막는다.
|
||||
* 2) computeModeSignature — agent/company/multi/brain 4개 축을 한 문자열로
|
||||
* 직렬화. 직전 signature 와 다르면 system prompt 에 "이전 모드 X → 현재 Y"
|
||||
* 한 줄 bridge 를 끼울 수 있다.
|
||||
*
|
||||
* 둘 다 instance state 의존 없는 pure transform — agent.ts 의 private 메서드를
|
||||
* 그대로 추출.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Agent Mode 진입 시 Astra 기본 프롬프트에서 다음 섹션들을 정규식으로 제거:
|
||||
* - Astra 페르소나 소개 → "specialized AI assistant" 로 대체
|
||||
* - [OUTPUT FORMAT] (## 요약/상세/제안 포맷 지시)
|
||||
* - [FOLLOW-UP QUESTION RULES]
|
||||
* - [ENGINEERING STANCE] (Astra 전용 응답 스타일)
|
||||
* - [NO EMOJIS - ABSOLUTE RULE] (에이전트 프롬프트에 이모지가 정당히 포함될 수 있음)
|
||||
* 유지: [CORE BEHAVIOR], [LOCAL PATH RULE], [STRICT GLOBAL RULES], [ACTION TAGS], [OPERATIONAL RULES]
|
||||
*
|
||||
* 마지막에 빈 줄 4개 이상 → 2개로 정리 (섹션 제거 후 남는 깨진 간격 수정).
|
||||
*/
|
||||
export function stripAstraFormattingForAgentMode(prompt: string): string {
|
||||
let stripped = prompt;
|
||||
|
||||
stripped = stripped.replace(
|
||||
/You are Astra, a Jarvis-style local project operating assistant\.\s*\nIf the user asks your name, say you are Astra\.\s*\n/,
|
||||
'You are a specialized AI assistant operating in Agent Mode.\n',
|
||||
);
|
||||
|
||||
stripped = stripped.replace(
|
||||
/\[OUTPUT FORMAT\][\s\S]*?(?=\[FOLLOW-UP QUESTION RULES\]|\[ENGINEERING STANCE\]|\[ACTION TAGS\])/,
|
||||
'',
|
||||
);
|
||||
|
||||
stripped = stripped.replace(
|
||||
/\[FOLLOW-UP QUESTION RULES\][\s\S]*?(?=\[ENGINEERING STANCE\]|\[ACTION TAGS\])/,
|
||||
'',
|
||||
);
|
||||
|
||||
stripped = stripped.replace(
|
||||
/\[ENGINEERING STANCE\][\s\S]*?(?=\[ACTION TAGS\])/,
|
||||
'',
|
||||
);
|
||||
|
||||
stripped = stripped.replace(
|
||||
/1\. \[NO EMOJIS - ABSOLUTE RULE\][^\n]*\n/,
|
||||
'',
|
||||
);
|
||||
|
||||
stripped = stripped.replace(/\n{4,}/g, '\n\n');
|
||||
|
||||
return stripped;
|
||||
}
|
||||
|
||||
/**
|
||||
* v2.2.69 — 현재 요청의 mode signature 를 단일 문자열로 직렬화. 직전 signature 와
|
||||
* 다르면 호출자가 system prompt 에 "이전 모드: X / 현재 모드: Y" 한 줄 bridge 를
|
||||
* 끼울 수 있다. 4개 축 (agent/company/multi/brain) 의 변화만 추적 — 변경 빈도가
|
||||
* 낮아 cache key 로도 충분.
|
||||
*/
|
||||
export function computeModeSignature(opts: {
|
||||
agentSkillName?: string;
|
||||
companyMode?: boolean;
|
||||
multiAgent?: boolean;
|
||||
brainName?: string;
|
||||
}): string {
|
||||
const parts = [
|
||||
`agent=${opts.agentSkillName || 'none'}`,
|
||||
`company=${opts.companyMode ? 'on' : 'off'}`,
|
||||
`multi=${opts.multiAgent ? 'on' : 'off'}`,
|
||||
`brain=${opts.brainName || '?'}`,
|
||||
];
|
||||
return parts.join('|');
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { ChatMessage } from '../../agent';
|
||||
|
||||
/**
|
||||
* 현재 사용자 메시지가 "얇은 follow-up" 인지 판정 — 직전 assistant 답변에 대한
|
||||
* 짧은 정정 / 사실 보강 / 확인 / 짧은 감사 등.
|
||||
*
|
||||
* 결과 true 면 Project Chronicle Guard 의 "요청 요약 / 사용자 의도 추론 / 핵심
|
||||
* 확인 질문 / 프로젝트 기록 대상 확인" 보일러플레이트를 *생략* 한다 — 이런 짧은
|
||||
* 후속 메시지에 매번 4-section 템플릿을 박는 건 응답 위생을 망친다 (R1/R3 위반).
|
||||
*
|
||||
* 보수적인 판정 — false-positive 가 false-negative 보다 위험 (긴 분석 요청에
|
||||
* 헤더가 없으면 사용자 경험 손상). 다음 *모든* 조건 만족할 때만 true:
|
||||
* (1) 직전 assistant 메시지가 history 에 존재 (즉 이게 follow-up 인 것)
|
||||
* (2) 짧다 (≤ 100자, 줄바꿈 후 trim 기준)
|
||||
* (3) 명확한 *질문* 신호가 없다 (?, "어떻게", "왜", "뭐", "어디", 등)
|
||||
* (4) 명확한 *새 작업 요청* 신호가 없다 (분석/평가/추천/비교/만들어줘/해줘/구현해/실행해 등)
|
||||
*/
|
||||
export function isThinFollowUp(prompt: string | null, history: ChatMessage[]): boolean {
|
||||
if (!prompt) return false;
|
||||
const t = prompt.trim();
|
||||
if (t.length === 0 || t.length > 100) return false;
|
||||
|
||||
// (1) 직전 assistant 메시지가 있어야 follow-up 임.
|
||||
const visible = history.filter(m => !m.internal && (m.role === 'user' || m.role === 'assistant'));
|
||||
const hasPriorAssistant = visible.some(m => m.role === 'assistant');
|
||||
if (!hasPriorAssistant) return false;
|
||||
|
||||
// (3) 질문 신호.
|
||||
if (/[??]/.test(t)) return false;
|
||||
if (/(어떻게|어디|언제|왜|뭐|누구|얼마|몇)/.test(t)) return false;
|
||||
if (/^\s*(what|how|why|where|when|who|which)\b/i.test(t)) return false;
|
||||
|
||||
// (4) 새 작업 요청 신호 (단순 명령어 / 분석 요청).
|
||||
if (/(분석|평가|추천|비교|만들|짜줘|작성|구현|돌려|실행|생성|검토|리뷰|review|analyze|implement|create|build)/i.test(t)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Thinking partner response contract — system prompt 에 항상 prepend 되는 6항목
|
||||
* 응답 규약. 100% stateless 한 정적 문자열이라 god-file agent.ts 에 박혀 있을
|
||||
* 이유 없음. 단위 테스트 / 다른 모드에서 재사용 / contract 수정 시 변경 범위
|
||||
* 최소화를 위해 별도 모듈로 격리.
|
||||
*
|
||||
* 6 항목은 모델이 "thinking partner" 페르소나 일관성 유지 + 칭찬 일변도 답변
|
||||
* 회피 + 결정 갈래 식별 + 검증 가능한 다음 액션 제시까지 강제하기 위한 규약.
|
||||
* 추가/수정 시 시스템 프롬프트 전체 paragraph 위치 영향 — 무작정 항목 늘리지 말 것.
|
||||
*/
|
||||
export function buildThinkingPartnerResponseContract(): string {
|
||||
return [
|
||||
'Thinking partner response contract:',
|
||||
'1. Start with a direct verdict, not a generic compliment.',
|
||||
'2. Separate confirmed facts from inferences.',
|
||||
'3. Name the strongest part of the direction and the weakest/missing part.',
|
||||
'4. Identify the real decision fork the user is facing.',
|
||||
'5. Suggest one small next action that would make the project direction clearer.',
|
||||
'6. If project evidence is thin, say what must be inspected next instead of pretending certainty.'
|
||||
].join('\n');
|
||||
}
|
||||
+17
-6
@@ -853,24 +853,35 @@ export class AgentEngine {
|
||||
* Fast-path 휴리스틱: prompt 가 "쪼갤 필요 없는 단순 케이스" 인지 즉시 판정.
|
||||
* 명백할 때만 true — 애매한 중간 길이는 false 로 반환해 outline LLM 이 판정하게 위임.
|
||||
*
|
||||
* 단순 기준:
|
||||
* - 길이 < 200자
|
||||
* - 본문 첨부 신호 없음 (코드 펜스, 긴 빈줄, --- 구분선)
|
||||
* - 분석/리서치 키워드 없음 (분석/리서치/조사/보고서/심층/설계/기획/꼼꼼히/상세히)
|
||||
* 단순 기준 (v2 — 키워드와 길이 *결합* 로 완화):
|
||||
* - 길이 ≥ 400자 → 무조건 chunked (긴 입력은 분할 가치 있음)
|
||||
* - 본문 첨부 신호 있음 → 무조건 chunked
|
||||
* - 분석/리서치 키워드 *있고* 길이 ≥ 80자 → chunked
|
||||
* - 분석/리서치 키워드 있어도 *80자 미만* → fast-path (예: "이 함수 분석해줘", "리뷰 요청")
|
||||
* - 키워드 없고 길이 < 400자 → fast-path
|
||||
*
|
||||
* 이전 v1 은 키워드 1개만 있어도 200자 미만이면 무조건 chunked → "5줄 코드 리뷰해줘"
|
||||
* 같은 짧은 케이스도 7회 LLM 호출했음. v2 는 *키워드 + 길이* 결합으로 진짜 무거운
|
||||
* 케이스만 chunked.
|
||||
*/
|
||||
public static isObviouslySimple(prompt: string): boolean {
|
||||
if (!prompt) return false;
|
||||
const trimmed = prompt.trim();
|
||||
if (trimmed.length === 0) return false;
|
||||
if (trimmed.length >= 200) return false;
|
||||
|
||||
// 본문 첨부 신호: 코드 펜스 / 긴 빈줄 / 마크다운 구분선 / 인용 다수.
|
||||
const hasAttachment = /```|\n\n\n|^---$|^> .*\n> /m.test(trimmed);
|
||||
if (hasAttachment) return false;
|
||||
|
||||
// 매우 긴 입력은 키워드 무관하게 chunked.
|
||||
if (trimmed.length >= 400) return false;
|
||||
|
||||
// 분석/구조화 키워드.
|
||||
const heavyKeyword = /(분석|리서치|조사|보고서|심층|상세히|꼼꼼히|기획|설계|아키텍처|리뷰|review|analyz|research|deep\s*analysis|strategy|proposal|보고|요약해서\s*정리)/i;
|
||||
if (heavyKeyword.test(trimmed)) return false;
|
||||
const hasKeyword = heavyKeyword.test(trimmed);
|
||||
|
||||
// 키워드 있고 입력이 길면(≥80자) chunked. 짧으면 (예: "이거 분석해줘") fast-path.
|
||||
if (hasKeyword && trimmed.length >= 80) return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,24 @@
|
||||
import * as vscode from 'vscode';
|
||||
import { MissionState } from './engine';
|
||||
|
||||
/**
|
||||
* @deprecated v2.2.76+ 부터 *기본 OFF*. 현재 시스템에서 거의 unreachable.
|
||||
*
|
||||
* 옛 5-stage 파이프라인의 최종 답변을 P-Reinforce v3.0 frontmatter + Reliability
|
||||
* Audit 표 형태로 wrap 하던 클래스. 사용자 피드백 ("일반 채팅에 wiki 메타데이터가
|
||||
* 끼어 보임") 으로 `AgentEngine.runMission` 에서 *명시적 opt-in* (options.config.
|
||||
* formatAsKnowledgeArtifact === true) 일 때만 호출되도록 게이팅됨.
|
||||
*
|
||||
* 사용 후보 (활성화하려면):
|
||||
* - 향후 명시적 "wiki 보관용 답변 모드" 기능 도입 시
|
||||
* - Datacollect 의 P-Reinforce v3.0 위키 합성 경로에서 격리된 호출
|
||||
*
|
||||
* 활성화 호출처가 늘어나지 않는다면 다음 라운드에 *완전 제거* 후보.
|
||||
*/
|
||||
export class WikiFormatter {
|
||||
/**
|
||||
* 최종 에이전트 출력물을 P-Reinforce v3.0 표준 포맷으로 변환합니다.
|
||||
* @deprecated 위 클래스 docstring 참고.
|
||||
*/
|
||||
public static format(content: string, state: MissionState): string {
|
||||
const now = new Date().toISOString();
|
||||
|
||||
Reference in New Issue
Block a user