160 lines
5.7 KiB
TypeScript
160 lines
5.7 KiB
TypeScript
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<string>();
|
|
|
|
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<string>,
|
|
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();
|
|
}
|