Files
connectai/src/skills/externalSkillLoader.ts
T
2026-05-11 12:44:38 +09:00

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