531 lines
17 KiB
JavaScript
531 lines
17 KiB
JavaScript
|
|
const fsPromises = require("node:fs/promises");
|
||
|
|
const path = require("node:path");
|
||
|
|
|
||
|
|
const USER_SKILLS_DIR_NAME = "Skills";
|
||
|
|
const USER_SKILLS_README_NAME = "README.txt";
|
||
|
|
const MAX_SKILL_BYTES = 24 * 1024;
|
||
|
|
const MAX_DESCRIPTION_LENGTH = 500;
|
||
|
|
const MAX_INDEX_SKILLS = 8;
|
||
|
|
const MAX_INDEX_DESCRIPTION_CHARS = 160;
|
||
|
|
const MAX_INDEX_LINE_CHARS = 1400;
|
||
|
|
const MAX_EXPLICIT_SKILLS = 4;
|
||
|
|
const MAX_MATCHED_SKILLS = 2;
|
||
|
|
const MAX_MATCHED_SKILL_CHARS = 6000;
|
||
|
|
const MAX_TOTAL_INJECTED_SKILL_CHARS = 12000;
|
||
|
|
const USER_SKILLS_README_CONTENT = [
|
||
|
|
"Netcatty user skills",
|
||
|
|
"",
|
||
|
|
"Add one folder per skill inside this directory.",
|
||
|
|
"Each skill folder must contain a SKILL.md file.",
|
||
|
|
"",
|
||
|
|
"Example layout:",
|
||
|
|
" Skills/",
|
||
|
|
" My Skill/",
|
||
|
|
" SKILL.md",
|
||
|
|
"",
|
||
|
|
"Minimal SKILL.md:",
|
||
|
|
" ---",
|
||
|
|
" name: My Skill",
|
||
|
|
" description: Short summary of what this skill helps with.",
|
||
|
|
" ---",
|
||
|
|
"",
|
||
|
|
" Write the skill instructions here.",
|
||
|
|
"",
|
||
|
|
"After adding or editing a skill, reopen the AI settings page or start a new chat to refresh the list.",
|
||
|
|
"",
|
||
|
|
].join("\n");
|
||
|
|
|
||
|
|
const STOPWORDS = new Set([
|
||
|
|
"the", "and", "for", "with", "that", "this", "from", "into", "when", "then",
|
||
|
|
"only", "your", "will", "should", "have", "has", "had", "using", "use",
|
||
|
|
"agent", "skill", "skills", "task", "file", "files", "user", "into", "about",
|
||
|
|
]);
|
||
|
|
|
||
|
|
function stripQuotes(value) {
|
||
|
|
const trimmed = String(value || "").trim();
|
||
|
|
if ((trimmed.startsWith('"') && trimmed.endsWith('"')) || (trimmed.startsWith("'") && trimmed.endsWith("'"))) {
|
||
|
|
return trimmed.slice(1, -1);
|
||
|
|
}
|
||
|
|
return trimmed;
|
||
|
|
}
|
||
|
|
|
||
|
|
function slugifySkill(value) {
|
||
|
|
return String(value || "")
|
||
|
|
.trim()
|
||
|
|
.toLowerCase()
|
||
|
|
.replace(/[^a-z0-9]+/g, "-")
|
||
|
|
.replace(/^-+|-+$/g, "");
|
||
|
|
}
|
||
|
|
|
||
|
|
function tokenize(value) {
|
||
|
|
return String(value || "")
|
||
|
|
.toLowerCase()
|
||
|
|
.split(/[^a-z0-9]+/i)
|
||
|
|
.map((token) => token.trim())
|
||
|
|
.filter((token) => token.length >= 3 && !STOPWORDS.has(token));
|
||
|
|
}
|
||
|
|
|
||
|
|
function escapeRegExp(value) {
|
||
|
|
return String(value || "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||
|
|
}
|
||
|
|
|
||
|
|
function truncateInlineText(value, maxChars) {
|
||
|
|
const normalized = String(value || "").replace(/\s+/g, " ").trim();
|
||
|
|
if (normalized.length <= maxChars) return normalized;
|
||
|
|
return `${normalized.slice(0, Math.max(0, maxChars - 3)).trimEnd()}...`;
|
||
|
|
}
|
||
|
|
|
||
|
|
function formatSkillReadWarning(error) {
|
||
|
|
const code = typeof error?.code === "string" ? error.code : null;
|
||
|
|
const message = typeof error?.message === "string" ? error.message : String(error || "Unknown error");
|
||
|
|
return code
|
||
|
|
? `Failed to read SKILL.md (${code}: ${message}).`
|
||
|
|
: `Failed to read SKILL.md (${message}).`;
|
||
|
|
}
|
||
|
|
|
||
|
|
function containsPlaintextPhrase(prompt, phrase) {
|
||
|
|
const trimmedPhrase = String(phrase || "").trim();
|
||
|
|
if (!trimmedPhrase) return false;
|
||
|
|
const pattern = new RegExp(`(^|\\s)${escapeRegExp(trimmedPhrase)}(?=$|\\s|[.,!?;:])`, "i");
|
||
|
|
return pattern.test(String(prompt || ""));
|
||
|
|
}
|
||
|
|
|
||
|
|
function parseFrontmatter(content) {
|
||
|
|
const match = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/.exec(content);
|
||
|
|
if (!match) {
|
||
|
|
return { attributes: {}, body: content, hasFrontmatter: false };
|
||
|
|
}
|
||
|
|
|
||
|
|
const attributes = {};
|
||
|
|
for (const rawLine of match[1].split(/\r?\n/)) {
|
||
|
|
const line = rawLine.trim();
|
||
|
|
if (!line || line.startsWith("#")) continue;
|
||
|
|
const colonIndex = line.indexOf(":");
|
||
|
|
if (colonIndex <= 0) continue;
|
||
|
|
const key = line.slice(0, colonIndex).trim();
|
||
|
|
const value = stripQuotes(line.slice(colonIndex + 1).trim());
|
||
|
|
if (key) attributes[key] = value;
|
||
|
|
}
|
||
|
|
|
||
|
|
return {
|
||
|
|
attributes,
|
||
|
|
body: content.slice(match[0].length),
|
||
|
|
hasFrontmatter: true,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
function summarizeSkillSlugs(skillsOrSlugs, maxItems = 4) {
|
||
|
|
const values = (Array.isArray(skillsOrSlugs) ? skillsOrSlugs : [])
|
||
|
|
.map((entry) => {
|
||
|
|
if (typeof entry === "string") return entry;
|
||
|
|
const slug = typeof entry?.slug === "string" ? entry.slug : "";
|
||
|
|
return slug;
|
||
|
|
})
|
||
|
|
.filter(Boolean)
|
||
|
|
.map((slug) => `/${slug}`);
|
||
|
|
if (values.length <= maxItems) {
|
||
|
|
return values.join(", ");
|
||
|
|
}
|
||
|
|
return `${values.slice(0, maxItems).join(", ")}, and ${values.length - maxItems} more`;
|
||
|
|
}
|
||
|
|
|
||
|
|
function getUserSkillsDir(electronApp) {
|
||
|
|
const userDataDir = electronApp?.getPath?.("userData");
|
||
|
|
if (!userDataDir) {
|
||
|
|
throw new Error("Electron app userData path is unavailable.");
|
||
|
|
}
|
||
|
|
return path.join(userDataDir, USER_SKILLS_DIR_NAME);
|
||
|
|
}
|
||
|
|
|
||
|
|
async function ensureUserSkillsDir(electronApp) {
|
||
|
|
const skillsDir = getUserSkillsDir(electronApp);
|
||
|
|
await fsPromises.mkdir(skillsDir, { recursive: true });
|
||
|
|
return skillsDir;
|
||
|
|
}
|
||
|
|
|
||
|
|
async function ensureUserSkillsReadme(electronApp) {
|
||
|
|
const skillsDir = await ensureUserSkillsDir(electronApp);
|
||
|
|
const dirEntries = await fsPromises.readdir(skillsDir);
|
||
|
|
if (dirEntries.length === 0) {
|
||
|
|
await fsPromises.writeFile(
|
||
|
|
path.join(skillsDir, USER_SKILLS_README_NAME),
|
||
|
|
USER_SKILLS_README_CONTENT,
|
||
|
|
"utf8",
|
||
|
|
);
|
||
|
|
}
|
||
|
|
return skillsDir;
|
||
|
|
}
|
||
|
|
|
||
|
|
async function scanUserSkills(electronApp) {
|
||
|
|
const skillsDir = await ensureUserSkillsReadme(electronApp);
|
||
|
|
const dirEntries = await fsPromises.readdir(skillsDir, { withFileTypes: true });
|
||
|
|
const skills = [];
|
||
|
|
const warnings = [];
|
||
|
|
|
||
|
|
for (const entry of dirEntries) {
|
||
|
|
// Only process actual directories, skipping symlinks for security
|
||
|
|
if (!entry.isDirectory() || entry.isSymbolicLink()) continue;
|
||
|
|
|
||
|
|
const dirName = entry.name;
|
||
|
|
// Basic path traversal protection: skip any directory name containing path separators
|
||
|
|
if (dirName.includes("/") || dirName.includes("\\") || dirName === ".." || dirName === ".") {
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
|
||
|
|
const skillDir = path.join(skillsDir, dirName);
|
||
|
|
const skillPath = path.join(skillDir, "SKILL.md");
|
||
|
|
const baseItem = {
|
||
|
|
id: dirName,
|
||
|
|
slug: slugifySkill(dirName),
|
||
|
|
directoryName: dirName,
|
||
|
|
directoryPath: skillDir,
|
||
|
|
skillPath,
|
||
|
|
name: dirName,
|
||
|
|
description: "",
|
||
|
|
status: "warning",
|
||
|
|
warnings: [],
|
||
|
|
};
|
||
|
|
|
||
|
|
try {
|
||
|
|
await fsPromises.access(skillPath);
|
||
|
|
} catch {
|
||
|
|
baseItem.warnings.push("Missing SKILL.md");
|
||
|
|
warnings.push(`${dirName}: Missing SKILL.md`);
|
||
|
|
skills.push(baseItem);
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
|
||
|
|
try {
|
||
|
|
const stat = await fsPromises.lstat(skillPath);
|
||
|
|
if (stat.isSymbolicLink()) {
|
||
|
|
baseItem.warnings.push("SKILL.md must not be a symbolic link.");
|
||
|
|
warnings.push(`${dirName}: SKILL.md must not be a symbolic link.`);
|
||
|
|
skills.push(baseItem);
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (!stat.isFile()) {
|
||
|
|
baseItem.warnings.push("SKILL.md must be a regular file.");
|
||
|
|
warnings.push(`${dirName}: SKILL.md must be a regular file.`);
|
||
|
|
skills.push(baseItem);
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (stat.size > MAX_SKILL_BYTES) {
|
||
|
|
baseItem.warnings.push(`SKILL.md is too large (${stat.size} bytes > ${MAX_SKILL_BYTES} bytes).`);
|
||
|
|
warnings.push(`${dirName}: SKILL.md is too large.`);
|
||
|
|
skills.push(baseItem);
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
|
||
|
|
const content = await fsPromises.readFile(skillPath, "utf8");
|
||
|
|
const { attributes, body, hasFrontmatter } = parseFrontmatter(content);
|
||
|
|
const name = stripQuotes(attributes.name || "").trim();
|
||
|
|
const description = stripQuotes(attributes.description || "").trim();
|
||
|
|
const usableSlug = slugifySkill(name || dirName);
|
||
|
|
|
||
|
|
if (!hasFrontmatter) {
|
||
|
|
baseItem.warnings.push("Missing YAML frontmatter.");
|
||
|
|
}
|
||
|
|
if (!name) {
|
||
|
|
baseItem.warnings.push("Missing frontmatter field: name.");
|
||
|
|
}
|
||
|
|
if (!description) {
|
||
|
|
baseItem.warnings.push("Missing frontmatter field: description.");
|
||
|
|
} else if (description.length > MAX_DESCRIPTION_LENGTH) {
|
||
|
|
baseItem.warnings.push(`Description is too long (${description.length} chars > ${MAX_DESCRIPTION_LENGTH}).`);
|
||
|
|
}
|
||
|
|
if (!usableSlug) {
|
||
|
|
baseItem.warnings.push("Skill name must include ASCII letters or digits to generate a usable slug.");
|
||
|
|
}
|
||
|
|
|
||
|
|
if (baseItem.warnings.length > 0) {
|
||
|
|
warnings.push(...baseItem.warnings.map((warning) => `${dirName}: ${warning}`));
|
||
|
|
skills.push({
|
||
|
|
...baseItem,
|
||
|
|
slug: usableSlug,
|
||
|
|
name: name || dirName,
|
||
|
|
description,
|
||
|
|
});
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
|
||
|
|
skills.push({
|
||
|
|
...baseItem,
|
||
|
|
slug: usableSlug,
|
||
|
|
name,
|
||
|
|
description,
|
||
|
|
status: "ready",
|
||
|
|
warnings: [],
|
||
|
|
body,
|
||
|
|
mtimeMs: stat.mtimeMs,
|
||
|
|
});
|
||
|
|
} catch (error) {
|
||
|
|
const warning = formatSkillReadWarning(error);
|
||
|
|
baseItem.warnings.push(warning);
|
||
|
|
warnings.push(`${dirName}: ${warning}`);
|
||
|
|
skills.push(baseItem);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const readySkillsBySlug = new Map();
|
||
|
|
for (const skill of skills) {
|
||
|
|
if (skill.status !== "ready" || !skill.slug) continue;
|
||
|
|
const matches = readySkillsBySlug.get(skill.slug);
|
||
|
|
if (matches) {
|
||
|
|
matches.push(skill);
|
||
|
|
} else {
|
||
|
|
readySkillsBySlug.set(skill.slug, [skill]);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
for (const [slug, duplicateSkills] of readySkillsBySlug.entries()) {
|
||
|
|
if (duplicateSkills.length < 2) continue;
|
||
|
|
const duplicateWarning = `Duplicate skill slug "${slug}". Rename the skill or change its frontmatter name.`;
|
||
|
|
for (const skill of duplicateSkills) {
|
||
|
|
skill.status = "warning";
|
||
|
|
skill.warnings = [...skill.warnings, duplicateWarning];
|
||
|
|
warnings.push(`${skill.directoryName}: ${duplicateWarning}`);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const readyCount = skills.filter((skill) => skill.status === "ready").length;
|
||
|
|
const warningCount = skills.filter((skill) => skill.status === "warning").length;
|
||
|
|
|
||
|
|
return {
|
||
|
|
directoryPath: skillsDir,
|
||
|
|
readyCount,
|
||
|
|
warningCount,
|
||
|
|
skills: skills.map((skill) => ({
|
||
|
|
id: skill.id,
|
||
|
|
slug: skill.slug,
|
||
|
|
directoryName: skill.directoryName,
|
||
|
|
directoryPath: skill.directoryPath,
|
||
|
|
skillPath: skill.skillPath,
|
||
|
|
name: skill.name,
|
||
|
|
description: skill.description,
|
||
|
|
status: skill.status,
|
||
|
|
warnings: skill.warnings,
|
||
|
|
})),
|
||
|
|
warnings,
|
||
|
|
_readySkills: skills.filter((skill) => skill.status === "ready"),
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Scores how well a skill matches a user prompt.
|
||
|
|
*
|
||
|
|
* Scored based on:
|
||
|
|
* - 50 points: Plain-text name/directory mention (e.g. prompt contains "my skill")
|
||
|
|
* - 1 point per keyword overlap (after tokenization/stopword filtering)
|
||
|
|
*
|
||
|
|
* @param {string} prompt - The user prompt
|
||
|
|
* @param {object} skill - The skill object from scanUserSkills
|
||
|
|
* @returns {number} The score (higher is better)
|
||
|
|
*/
|
||
|
|
function scoreSkillMatch(prompt, skill) {
|
||
|
|
const name = String(skill.name || "").trim();
|
||
|
|
const directoryName = String(skill.directoryName || "").trim();
|
||
|
|
|
||
|
|
// High weight for an exact plain-text mention of the skill name.
|
||
|
|
if (
|
||
|
|
(name && containsPlaintextPhrase(prompt, name)) ||
|
||
|
|
(directoryName && containsPlaintextPhrase(prompt, directoryName))
|
||
|
|
) {
|
||
|
|
return 50;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Fallback to token keyword overlap
|
||
|
|
const promptTokens = new Set(tokenize(prompt));
|
||
|
|
const skillTokens = tokenize(`${skill.name} ${skill.description}`);
|
||
|
|
let overlap = 0;
|
||
|
|
for (const token of skillTokens) {
|
||
|
|
if (promptTokens.has(token)) overlap += 1;
|
||
|
|
}
|
||
|
|
return overlap;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Builds the contextual prompt part from matched user skills.
|
||
|
|
*
|
||
|
|
* @param {object} electronApp - The Electron app instance
|
||
|
|
* @param {string} prompt - The user's input prompt
|
||
|
|
* @param {string[]} selectedSkillSlugs - Explicitly requested skill slugs
|
||
|
|
* @returns {Promise<{context: string, status: object}>} The built prompt part and scan status
|
||
|
|
*/
|
||
|
|
async function buildUserSkillsContext(electronApp, prompt, selectedSkillSlugs = []) {
|
||
|
|
const status = await scanUserSkills(electronApp);
|
||
|
|
const readySkills = status._readySkills || [];
|
||
|
|
const trimmedPrompt = String(prompt || "").trim();
|
||
|
|
if (readySkills.length === 0) {
|
||
|
|
return { context: "", status };
|
||
|
|
}
|
||
|
|
|
||
|
|
const indexSkills = readySkills.slice(0, MAX_INDEX_SKILLS);
|
||
|
|
let remainingCount = Math.max(readySkills.length - indexSkills.length, 0);
|
||
|
|
const indexEntries = [];
|
||
|
|
let indexChars = 0;
|
||
|
|
|
||
|
|
for (const skill of indexSkills) {
|
||
|
|
const entry = `${skill.name}: ${truncateInlineText(skill.description, MAX_INDEX_DESCRIPTION_CHARS)}`;
|
||
|
|
const separatorChars = indexEntries.length > 0 ? 2 : 0;
|
||
|
|
if (indexChars + separatorChars + entry.length > MAX_INDEX_LINE_CHARS) {
|
||
|
|
remainingCount += indexSkills.length - indexEntries.length;
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
indexEntries.push(entry);
|
||
|
|
indexChars += separatorChars + entry.length;
|
||
|
|
}
|
||
|
|
|
||
|
|
const indexLine = indexEntries.join("; ");
|
||
|
|
|
||
|
|
const orderedExplicitSlugs = [];
|
||
|
|
const seenExplicitSlugs = new Set();
|
||
|
|
for (const rawSlug of Array.isArray(selectedSkillSlugs) ? selectedSkillSlugs : []) {
|
||
|
|
const slug = slugifySkill(rawSlug);
|
||
|
|
if (!slug || seenExplicitSlugs.has(slug)) continue;
|
||
|
|
seenExplicitSlugs.add(slug);
|
||
|
|
orderedExplicitSlugs.push(slug);
|
||
|
|
}
|
||
|
|
|
||
|
|
const additionalExplicitCount = Math.max(orderedExplicitSlugs.length - MAX_EXPLICIT_SKILLS, 0);
|
||
|
|
const cappedExplicitSlugs = orderedExplicitSlugs.slice(0, MAX_EXPLICIT_SKILLS);
|
||
|
|
const explicitSlugSet = new Set(cappedExplicitSlugs);
|
||
|
|
const readySkillsBySlug = new Map(readySkills.map((skill) => [skill.slug, skill]));
|
||
|
|
const explicitSkills = [];
|
||
|
|
const unavailableExplicitSlugs = [];
|
||
|
|
for (const slug of cappedExplicitSlugs) {
|
||
|
|
const skill = readySkillsBySlug.get(slug);
|
||
|
|
if (skill) {
|
||
|
|
explicitSkills.push(skill);
|
||
|
|
} else {
|
||
|
|
unavailableExplicitSlugs.push(slug);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
const matchedSkills = readySkills
|
||
|
|
.filter((skill) => !explicitSlugSet.has(skill.slug))
|
||
|
|
.map((skill) => ({ skill, score: scoreSkillMatch(trimmedPrompt, skill) }))
|
||
|
|
.filter((entry) => entry.score >= 2)
|
||
|
|
.sort((left, right) => right.score - left.score)
|
||
|
|
.slice(0, MAX_MATCHED_SKILLS)
|
||
|
|
.map((entry) => entry.skill);
|
||
|
|
|
||
|
|
const finalSkills = [...explicitSkills, ...matchedSkills];
|
||
|
|
|
||
|
|
const parts = [
|
||
|
|
"User-managed skills are installed in Netcatty.",
|
||
|
|
`Available user skills: ${indexLine}${remainingCount > 0 ? `; and ${remainingCount} more.` : "."}`,
|
||
|
|
"Use a user-managed skill only when it clearly matches the current request.",
|
||
|
|
];
|
||
|
|
|
||
|
|
if (additionalExplicitCount > 0) {
|
||
|
|
parts.push(
|
||
|
|
`The user selected ${additionalExplicitCount} additional Netcatty user skills that were omitted to stay within the prompt budget.`,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
if (unavailableExplicitSlugs.length > 0) {
|
||
|
|
parts.push(
|
||
|
|
`The user explicitly selected these Netcatty user skills for this request, but their content is currently unavailable: ${summarizeSkillSlugs(unavailableExplicitSlugs)}.`,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
if (finalSkills.length > 0) {
|
||
|
|
const includedSkillSections = [];
|
||
|
|
const omittedSkills = [];
|
||
|
|
const truncatedSkills = [];
|
||
|
|
let remainingSkillChars = MAX_TOTAL_INJECTED_SKILL_CHARS;
|
||
|
|
let budgetStopIndex = finalSkills.length;
|
||
|
|
|
||
|
|
for (let index = 0; index < finalSkills.length; index += 1) {
|
||
|
|
const skill = finalSkills[index];
|
||
|
|
const heading = `### ${skill.name}\n`;
|
||
|
|
const maxBodyChars = Math.min(
|
||
|
|
MAX_MATCHED_SKILL_CHARS,
|
||
|
|
Math.max(remainingSkillChars - heading.length, 0),
|
||
|
|
);
|
||
|
|
if (maxBodyChars <= 0) {
|
||
|
|
omittedSkills.push(skill);
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
|
||
|
|
const rawBody = String(skill.body || "").trim();
|
||
|
|
if (!rawBody) {
|
||
|
|
omittedSkills.push(skill);
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (rawBody.length > maxBodyChars && includedSkillSections.length > 0) {
|
||
|
|
omittedSkills.push(skill);
|
||
|
|
budgetStopIndex = index;
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
|
||
|
|
const body = rawBody.slice(0, maxBodyChars);
|
||
|
|
if (!body) {
|
||
|
|
omittedSkills.push(skill);
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
|
||
|
|
includedSkillSections.push(`${heading}${body}`);
|
||
|
|
remainingSkillChars -= heading.length + body.length;
|
||
|
|
|
||
|
|
if (body.length < rawBody.length) {
|
||
|
|
truncatedSkills.push(skill);
|
||
|
|
budgetStopIndex = index + 1;
|
||
|
|
break;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
parts.push("Matched user-managed skills for this request:");
|
||
|
|
|
||
|
|
if (includedSkillSections.length > 0) {
|
||
|
|
parts.push(...includedSkillSections);
|
||
|
|
}
|
||
|
|
|
||
|
|
const omittedAfterIncluded = finalSkills.slice(budgetStopIndex);
|
||
|
|
for (const skill of omittedAfterIncluded) {
|
||
|
|
if (!omittedSkills.includes(skill) && !truncatedSkills.includes(skill)) {
|
||
|
|
omittedSkills.push(skill);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if (truncatedSkills.length > 0) {
|
||
|
|
parts.push(
|
||
|
|
`Some matched user-managed skill content was truncated to stay within the prompt budget: ${summarizeSkillSlugs(truncatedSkills)}.`,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
if (omittedSkills.length > 0) {
|
||
|
|
parts.push(
|
||
|
|
`Additional matched user-managed skills were omitted to stay within the prompt budget: ${summarizeSkillSlugs(omittedSkills)}.`,
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return {
|
||
|
|
context: parts.join("\n\n"),
|
||
|
|
status,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
function toPublicUserSkillsStatus(status) {
|
||
|
|
if (!status || typeof status !== "object") {
|
||
|
|
return status;
|
||
|
|
}
|
||
|
|
const publicStatus = { ...status };
|
||
|
|
delete publicStatus._readySkills;
|
||
|
|
return publicStatus;
|
||
|
|
}
|
||
|
|
|
||
|
|
module.exports = {
|
||
|
|
USER_SKILLS_DIR_NAME,
|
||
|
|
getUserSkillsDir,
|
||
|
|
ensureUserSkillsDir,
|
||
|
|
ensureUserSkillsReadme,
|
||
|
|
scanUserSkills,
|
||
|
|
buildUserSkillsContext,
|
||
|
|
toPublicUserSkillsStatus,
|
||
|
|
};
|