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,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');
|
||||
}
|
||||
Reference in New Issue
Block a user