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:
g1nation
2026-05-25 09:59:32 +09:00
parent 4153f640c2
commit 0a97324f1b
149 changed files with 14628 additions and 6927 deletions
+328
View File
@@ -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');
}