Files
connectai/src/lib/contextBuilders/localProjectPath.ts
T
koriweb 6e4de611e6 로컬 프로젝트 분석: STATUS.md 를 현재 상태의 단일 기준으로 승격
- findPriorityProjectFiles: STATUS.md 를 우선 프리뷰 대상에 추가하고
  랭킹을 package.json 바로 다음(README 앞)으로 배치.
- 경로 분석/의도 가이던스: STATUS.md(작업 단위마다 갱신)를 먼저 읽고
  현재 페이즈/진행 판단의 앵커로 삼도록 지시. docs/plans/* 등 설계
  문서는 작성 시점의 의도라 낡을 수 있으며, 충돌 시 STATUS.md 가
  우선하고 설계 문서가 대체되었음을 명시적으로 말하게 함.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-20 09:59:39 +09:00

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');
}