import * as fs from 'fs'; import * as path from 'path'; import { resolvePathInput } from '../lib/paths'; import { logError, logInfo } from '../utils'; /** * Loads markdown content from external skill folders/files mapped to an agent. * * Unlike `knowledgeFolders` (which feed the RAG retriever and are search-gated), * `skillFolders` entries are loaded eagerly and concatenated into the agent's * system prompt — same role as the agent's own `.md` file, just sourced from * the user's own library outside `.agent/skills/`. * * Each spec can be: * - an absolute folder path → every `.md` file directly inside is loaded * - an absolute file path ending in `.md` → that single file is loaded * - `~`-prefixed forms of either of the above * * We intentionally do NOT recurse into subfolders. Users who want hierarchy * pick the specific subfolder; that keeps the contract predictable and avoids * accidentally pulling in large trees. * * Skills outside the brain root are allowed (unlike knowledgeFolders) because * skill libraries typically live with the user (e.g. ~/Documents/agent-skills), * not inside a particular brain. Read-only, never written. */ const MAX_SKILL_FILES = 64; const MAX_SKILL_BYTES = 512 * 1024; // 512 KB per file ceiling export interface LoadedSkill { /** Display name derived from the filename (no extension). */ name: string; /** Absolute path the content was read from. */ filePath: string; /** Raw markdown body. */ content: string; } export interface LoadedSkillBundle { skills: LoadedSkill[]; /** Specs that couldn't be resolved or contained no .md files. For UI hints. */ skipped: { spec: string; reason: string }[]; } export function loadExternalSkills(specs: string[] | undefined): LoadedSkillBundle { const skills: LoadedSkill[] = []; const skipped: { spec: string; reason: string }[] = []; if (!Array.isArray(specs) || specs.length === 0) return { skills, skipped }; const seen = new Set(); for (const rawSpec of specs) { if (skills.length >= MAX_SKILL_FILES) { skipped.push({ spec: rawSpec, reason: `최대 스킬 파일 수(${MAX_SKILL_FILES}) 초과` }); continue; } const resolved = resolvePathInput(rawSpec || ''); if (!resolved) { skipped.push({ spec: rawSpec, reason: '경로를 해석할 수 없음 (절대경로 또는 ~ 형식이어야 합니다)' }); continue; } let stat: fs.Stats; try { stat = fs.statSync(resolved); } catch { skipped.push({ spec: rawSpec, reason: '경로가 존재하지 않음' }); continue; } if (stat.isFile()) { if (!/\.(md|markdown)$/i.test(resolved)) { skipped.push({ spec: rawSpec, reason: '.md 파일이 아님' }); continue; } _tryAddFile(resolved, skills, skipped, seen, rawSpec); continue; } if (stat.isDirectory()) { let entries: string[] = []; try { entries = fs.readdirSync(resolved); } catch (e: any) { skipped.push({ spec: rawSpec, reason: `폴더 읽기 실패: ${e?.message ?? e}` }); continue; } const mdFiles = entries .filter((n) => /\.(md|markdown)$/i.test(n)) .map((n) => path.join(resolved, n)) .sort(); if (mdFiles.length === 0) { skipped.push({ spec: rawSpec, reason: '폴더에 .md 파일이 없음' }); continue; } for (const filePath of mdFiles) { if (skills.length >= MAX_SKILL_FILES) break; _tryAddFile(filePath, skills, skipped, seen, rawSpec); } continue; } skipped.push({ spec: rawSpec, reason: '파일도 폴더도 아님 (심볼릭 링크?)' }); } if (skills.length > 0) { logInfo('external-skills: loaded.', { count: skills.length, skipped: skipped.length }); } return { skills, skipped }; } function _tryAddFile( absPath: string, skills: LoadedSkill[], skipped: { spec: string; reason: string }[], seen: Set, originalSpec: string ): void { const key = path.normalize(absPath); if (seen.has(key)) return; seen.add(key); let content = ''; try { const fileStat = fs.statSync(absPath); if (fileStat.size > MAX_SKILL_BYTES) { skipped.push({ spec: originalSpec, reason: `${path.basename(absPath)}: 파일이 너무 큼 (${fileStat.size} bytes > ${MAX_SKILL_BYTES})`, }); return; } content = fs.readFileSync(absPath, 'utf8'); } catch (e: any) { skipped.push({ spec: originalSpec, reason: `${path.basename(absPath)} 읽기 실패: ${e?.message ?? e}` }); logError('external-skills: file read failed.', { absPath, error: e?.message ?? String(e) }); return; } const name = path.basename(absPath).replace(/\.(md|markdown)$/i, ''); skills.push({ name, filePath: absPath, content }); } /** * Format the bundle as a single markdown block ready to append to the agent's * system prompt. Returns empty string when no skills loaded — caller can then * skip injection entirely without an empty section header. */ export function formatSkillsAsPromptBlock(bundle: LoadedSkillBundle): string { if (!bundle.skills.length) return ''; const parts: string[] = ['## External Skills', '']; for (const skill of bundle.skills) { parts.push(`### Skill: ${skill.name}`); parts.push(skill.content.trim()); parts.push(''); } return parts.join('\n').trim(); }