6e4de611e6
- findPriorityProjectFiles: STATUS.md 를 우선 프리뷰 대상에 추가하고 랭킹을 package.json 바로 다음(README 앞)으로 배치. - 경로 분석/의도 가이던스: STATUS.md(작업 단위마다 갱신)를 먼저 읽고 현재 페이즈/진행 판단의 앵커로 삼도록 지시. docs/plans/* 등 설계 문서는 작성 시점의 의도라 낡을 수 있으며, 충돌 시 STATUS.md 가 우선하고 설계 문서가 대체되었음을 명시적으로 말하게 함. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
333 lines
17 KiB
TypeScript
333 lines
17 KiB
TypeScript
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',
|
|
'STATUS.md',
|
|
'status.md',
|
|
'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 (/^status\.md$/i.test(relative)) return 1;
|
|
if (/readme\.md$/i.test(file)) return 2;
|
|
if (/^src[\\/]App\.tsx$/i.test(relative)) return 3;
|
|
if (/^src[\\/]main\.tsx$/i.test(relative)) return 4;
|
|
if (/^src[\\/]features[\\/]game[\\/]hooks[\\/]useGameEngine\.ts$/i.test(relative)) return 5;
|
|
if (/^src[\\/]features[\\/]game[\\/]systems[\\/]/i.test(relative)) return 6;
|
|
if (/^src[\\/]features[\\/]game[\\/]ui[\\/]/i.test(relative)) return 7;
|
|
if (/^src[\\/]/i.test(relative)) return 8;
|
|
if (/^docs[\\/]|\.md$/i.test(relative)) return 9;
|
|
return 10;
|
|
};
|
|
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 a STATUS.md is present in the priority previews or scanned tree, it is the canonical current-state record for this project (updated per work unit) — read it FIRST and let it anchor your understanding of "current" phase/status. Design docs and plans under docs/ (e.g. dated design specs, docs/plans/*) describe intent at the time they were written and may be stale; if their content conflicts with STATUS.md (different phase, different chosen approach), STATUS.md wins and you should say so explicitly rather than silently presenting the older doc as current.',
|
|
'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');
|
|
}
|