/** * Replace the user's home directory prefix with `~` in skill file paths * to reduce system prompt token usage. Models understand `~` expansion, * and the read tool resolves `~` to the home directory. * * Example: `/Users/alice/.bun/.../skills/github/SKILL.md` * → `~/.bun/.../skills/github/SKILL.md` * * Saves ~5–6 tokens per skill path × N skills ≈ 400–600 tokens total.
*/ function resolveUserHomeDir(): string | undefined { return resolveOsHomeDir(process.env, os.homedir);
}
function resolveCompactHomePrefixes(): string[] { const homes = [resolveHomeDir(), resolveUserHomeDir(), resolveNativeUserHomeDir()].filter(
(home): home is string => !!home,
); const resolvedHomes = homes.map((home) => path.resolve(home)); const realHomes = resolvedHomes
.map((home) => tryRealpath(home))
.filter((home): home is string => !!home); return [...resolvedHomes, ...realHomes]
.filter((home, index, all) => all.indexOf(home) === index)
.sort((a, b) => b.length - a.length);
}
function compactSkillPaths(skills: Skill[]): Skill[] { const homes = resolveCompactHomePrefixes(); if (homes.length === 0) return skills; return skills.map((s) => ({
...s,
filePath: compactHomePath(s.filePath, homes),
}));
}
function compactHomePath(filePath: string, homes: readonly string[]): string { for (const home of homes) { const prefix = home.endsWith(path.sep) ? home : home + path.sep; if (filePath.startsWith(prefix)) { return"~/" + filePath.slice(prefix.length);
}
} return filePath;
}
function compactPathForConsoleMessage(filePath: string): string { return compactHomePath(filePath, resolveCompactHomePrefixes());
}
function isSkillVisibleInAvailableSkillsPrompt(entry: SkillEntry): boolean { if (entry.exposure) { return entry.exposure.includeInAvailableSkillsPrompt !== false;
} if (entry.invocation) { return entry.invocation.disableModelInvocation !== true;
} return entry.skill.disableModelInvocation !== true;
}
function filterSkillEntries(
entries: SkillEntry[],
config?: OpenClawConfig,
skillFilter?: string[],
eligibility?: SkillEligibilityContext,
): SkillEntry[] {
let filtered = entries.filter((entry) => shouldIncludeSkill({ entry, config, eligibility })); // If skillFilter is provided, only include skills in the filter list. if (skillFilter !== undefined) { const normalized = normalizeSkillFilter(skillFilter) ?? []; const label = normalized.length > 0 ? normalized.join(", ") : "(none)";
skillsLogger.debug(`Applying skill filter: ${label}`);
filtered =
normalized.length > 0
? filtered.filter((entry) => normalized.includes(entry.skill.name))
: [];
skillsLogger.debug(
`After skill filter: ${filtered.map((entry) => entry.skill.name).join(", ") || "(none)"}`,
);
} return filtered;
}
// Heuristic: if `dir/skills/*/SKILL.md` exists for any entry, treat `dir/skills` as the real root. // Note: don't stop at 25, but keep a cap to avoid pathological scans. const nestedDirs = listChildDirectories(nested); const scanLimit = Math.max(0, opts?.maxEntriesToScan ?? 100); const toScan = scanLimit === 0 ? [] : nestedDirs.slice(0, Math.min(nestedDirs.length, scanLimit));
for (const name of toScan) { const skillMd = path.join(nested, name, "SKILL.md"); if (fs.existsSync(skillMd)) { return { baseDir: nested, note: `Detected nested skills root at ${nested}` };
}
} return { baseDir: dir };
}
function unwrapLoadedSkillRecords(loaded: unknown): LoadedSkillRecord[] { if (Array.isArray(loaded)) { return (loaded as Skill[]).map((skill) => ({ skill }));
} if (loaded && typeof loaded === "object" && "skills" in loaded) { const skills = (loaded as { skills?: unknown }).skills; if (Array.isArray(skills)) { const loadedResult = loaded as { frontmatterByFilePath?: unknown }; const frontmatterByFilePath =
loadedResult.frontmatterByFilePath instanceof Map
? (loadedResult.frontmatterByFilePath as ReadonlyMap<string, ParsedSkillFrontmatter>)
: undefined; return (skills as Skill[]).map((skill) => ({
skill,
frontmatter: frontmatterByFilePath?.get(skill.filePath),
}));
}
} return [];
}
const merged = new Map<string, LoadedSkillRecord>(); // Precedence: extra < bundled < managed < agents-skills-personal < agents-skills-project < workspace for (const record of extraSkills) {
merged.set(record.skill.name, record);
} for (const record of bundledSkills) {
merged.set(record.skill.name, record);
} for (const record of managedSkills) {
merged.set(record.skill.name, record);
} for (const record of personalAgentsSkills) {
merged.set(record.skill.name, record);
} for (const record of projectAgentsSkills) {
merged.set(record.skill.name, record);
} for (const record of workspaceSkills) {
merged.set(record.skill.name, record);
}
const skillEntries: SkillEntry[] = Array.from(merged.values())
.sort((a, b) => a.skill.name.localeCompare(b.skill.name, "en"))
.map((record) => { const skill = record.skill; const frontmatter =
record.frontmatter ??
readSkillFrontmatterSafe({
rootDir: skill.baseDir,
filePath: skill.filePath,
maxBytes: limits.maxSkillFileBytes,
}) ??
({} as ParsedSkillFrontmatter); const invocation = resolveSkillInvocationPolicy(frontmatter); return {
skill,
frontmatter,
metadata: resolveOpenClawMetadata(frontmatter),
invocation,
exposure: {
includeInRuntimeRegistry: true, // Freshly loaded entries preserve the documented disable-model-invocation // contract, while legacy entries without exposure metadata still use the // fallback in isSkillVisibleInAvailableSkillsPrompt().
includeInAvailableSkillsPrompt: invocation.disableModelInvocation !== true,
userInvocable: invocation.userInvocable !== false,
},
};
}); return skillEntries;
}
/** * Compact skill catalog: name + location only (no description). * Used as a fallback when the full format exceeds the char budget, * preserving awareness of all skills before resorting to dropping.
*/
export function formatSkillsCompact(skills: Skill[]): string { if (skills.length === 0) return""; const lines = [ "\n\nThe following skills provide specialized instructions for specific tasks.", "Use the read tool to load a skill's file when the task matches its name.", "When a skill file references a relative path, resolve it against the skill directory (parent of SKILL.md / dirname of the path) and use that absolute path in tool commands.", "", "<available_skills>",
]; for (const skill of skills) {
lines.push(" <skill>");
lines.push(` <name>${escapeXml(skill.name)}</name>`);
lines.push(` <location>${escapeXml(skill.filePath)}</location>`);
lines.push(" </skill>");
}
lines.push("</available_skills>"); return lines.join("\n");
}
// Budget reserved for the compact-mode warning line prepended by the caller. const COMPACT_WARNING_OVERHEAD = 150;
// Reserve space for the warning line the caller prepends in compact mode. const compactBudget = limits.maxSkillsPromptChars - COMPACT_WARNING_OVERHEAD; const fitsCompact = (skills: Skill[]): boolean =>
formatSkillsCompact(skills).length <= compactBudget;
if (!fitsFull(skillsForPrompt)) { // Full format exceeds budget. Try compact (name + location, no description) // to preserve awareness of all skills before dropping any. if (fitsCompact(skillsForPrompt)) {
compact = true; // No skills dropped — only format downgraded. Preserve existing truncated state.
} else { // Compact still too large — binary search the largest prefix that fits.
compact = true;
let lo = 0;
let hi = skillsForPrompt.length; while (lo < hi) { const mid = Math.ceil((lo + hi) / 2); if (fitsCompact(skillsForPrompt.slice(0, mid))) {
lo = mid;
} else {
hi = mid - 1;
}
}
skillsForPrompt = skillsForPrompt.slice(0, lo);
truncated = true;
}
}
type WorkspaceSkillBuildOptions = {
config?: OpenClawConfig;
managedSkillsDir?: string;
bundledSkillsDir?: string;
entries?: SkillEntry[];
agentId?: string; /** If provided, only include skills with these names */
skillFilter?: string[];
eligibility?: SkillEligibilityContext;
};
function resolveEffectiveWorkspaceSkillFilter(
opts?: WorkspaceSkillBuildOptions,
): string[] | undefined { if (opts?.skillFilter !== undefined) { return normalizeSkillFilter(opts.skillFilter);
} if (!opts?.config || !opts.agentId) { return undefined;
} return resolveEffectiveAgentSkillFilter(opts.config, opts.agentId);
}
function resolveWorkspaceSkillPromptState(
workspaceDir: string,
opts?: WorkspaceSkillBuildOptions,
): {
eligible: SkillEntry[];
prompt: string;
resolvedSkills: Skill[];
} { const skillEntries = opts?.entries ?? loadSkillEntries(workspaceDir, opts); const effectiveSkillFilter = resolveEffectiveWorkspaceSkillFilter(opts); const eligible = filterSkillEntries(
skillEntries,
opts?.config,
effectiveSkillFilter,
opts?.eligibility,
); const promptEntries = eligible.filter((entry) => isSkillVisibleInAvailableSkillsPrompt(entry)); const remoteNote = opts?.eligibility?.remote?.note?.trim(); const resolvedSkills = promptEntries.map((entry) => entry.skill); // Derive prompt-facing skills with compacted paths (e.g. ~/...) once. // Budget checks and final render both use this same representation so the // tier decision is based on the exact strings that end up in the prompt. // resolvedSkills keeps canonical paths for snapshot / runtime consumers. const promptSkills = compactSkillPaths(resolvedSkills)
.slice()
.sort((a, b) => a.name.localeCompare(b.name, "en")); const { skillsForPrompt, truncated, compact } = applySkillsPromptLimits({
skills: promptSkills,
config: opts?.config,
agentId: opts?.agentId,
}); const truncationNote = truncated
? `⚠️ Skills truncated: included ${skillsForPrompt.length} of ${resolvedSkills.length}${compact ? " (compact format, descriptions omitted)" : ""}. Run \`openclaw skills check\` to audit.`
: compact
? `⚠️ Skills catalog using compact format (descriptions omitted). Run \`openclaw skills check\` to audit.`
: ""; const prompt = [
remoteNote,
truncationNote,
compact ? formatSkillsCompact(skillsForPrompt) : formatSkillsForPrompt(skillsForPrompt),
]
.filter(Boolean)
.join("\n"); return { eligible, prompt, resolvedSkills };
}
Die Informationen auf dieser Webseite wurden
nach bestem Wissen sorgfältig zusammengestellt. Es wird jedoch weder Vollständigkeit, noch Richtigkeit,
noch Qualität der bereit gestellten Informationen zugesichert.
Bemerkung:
Die farbliche Syntaxdarstellung und die Messung sind noch experimentell.