fix: Use size-based threshold for skills memory block format (#226)

This commit is contained in:
Cameron
2025-12-18 08:32:26 -08:00
committed by GitHub
parent 1bd40b25f5
commit 9589ce177c
5 changed files with 277 additions and 6 deletions

View File

@@ -1,6 +1,6 @@
---
label: skills
description: A memory block listing all available skills with their metadata (name and description). This block is auto-generated based on the `.skills` directory. Do not manually edit this block.
description: A memory block listing all available skills. Auto-generated from the `.skills` directory - do not manually edit. When there are few skills, shows full metadata (name, description). When there are many skills, shows a compact directory tree structure to save space. To use a skill, load it into memory or read the SKILL.md file directly.
---
[CURRENTLY EMPTY]

View File

@@ -51,6 +51,12 @@ export interface SkillDiscoveryError {
export const SKILLS_DIR = ".skills";
/**
* Skills block character limit.
* If formatted skills exceed this, fall back to compact tree format.
*/
const SKILLS_BLOCK_CHAR_LIMIT = 20000;
/** origin/main
* Discovers skills by recursively searching for SKILL.MD files
* @param skillsPath - The directory to search for skills (default: .skills in current directory)
* @returns A result containing discovered skills and any errors
@@ -204,12 +210,82 @@ async function parseSkillFile(
}
/**
* Formats discovered skills as a string for the skills memory block
* Formats skills as a compact directory tree structure
* @param skills - Array of discovered skills
* @param skillsDirectory - Absolute path to the skills directory
* @returns Formatted string representation of skills
* @returns Tree-structured string representation
*/
export function formatSkillsForMemory(
function formatSkillsAsTree(skills: Skill[], skillsDirectory: string): string {
let output = `Skills Directory: ${skillsDirectory}\n\n`;
if (skills.length === 0) {
return `${output}[NO SKILLS AVAILABLE]`;
}
output += `Note: Many skills available - showing directory structure only. For each skill path shown below, you can either:\n`;
output += `- Load it persistently into memory using the path (e.g., "ai/tools/mcp-builder")\n`;
output += `- Read ${skillsDirectory}/{path}/SKILL.md directly to preview without loading\n\n`;
// Build tree structure from skill IDs
interface TreeNode {
[key: string]: TreeNode | null;
}
const tree: TreeNode = {};
// Parse all skill IDs into tree structure
for (const skill of skills) {
const parts = skill.id.split("/");
let current = tree;
for (let i = 0; i < parts.length; i++) {
const part = parts[i];
if (!part) continue;
// Last part is the skill name (leaf node)
if (i === parts.length - 1) {
current[part] = null;
} else {
// Intermediate directory
if (!current[part]) {
current[part] = {};
}
current = current[part] as TreeNode;
}
}
}
// Render tree with indentation
function renderTree(node: TreeNode, indent: string = ""): string {
let result = "";
const entries = Object.entries(node).sort(([a], [b]) => a.localeCompare(b));
for (const [name, children] of entries) {
if (children === null) {
// Leaf node (skill)
result += `${indent}${name}\n`;
} else {
// Directory node
result += `${indent}${name}/\n`;
result += renderTree(children, `${indent} `);
}
}
return result;
}
output += renderTree(tree);
return output.trim();
}
/**
* Formats discovered skills with full metadata
* @param skills - Array of discovered skills
* @param skillsDirectory - Absolute path to the skills directory
* @returns Full metadata string representation
*/
function formatSkillsWithMetadata(
skills: Skill[],
skillsDirectory: string,
): string {
@@ -272,3 +348,31 @@ function formatSkill(skill: Skill): string {
output += "\n";
return output;
}
/**
* Formats discovered skills as a string for the skills memory block.
* Tries full metadata format first, falls back to compact tree if it exceeds limit.
* @param skills - Array of discovered skills
* @param skillsDirectory - Absolute path to the skills directory
* @returns Formatted string representation of skills
*/
export function formatSkillsForMemory(
skills: Skill[],
skillsDirectory: string,
): string {
// Handle empty case
if (skills.length === 0) {
return `Skills Directory: ${skillsDirectory}\n\n[NO SKILLS AVAILABLE]`;
}
// Try full metadata format first
const fullFormat = formatSkillsWithMetadata(skills, skillsDirectory);
// If within limit, use full format
if (fullFormat.length <= SKILLS_BLOCK_CHAR_LIMIT) {
return fullFormat;
}
// Otherwise fall back to compact tree format
return formatSkillsAsTree(skills, skillsDirectory);
}

View File

@@ -66,7 +66,7 @@ export function SetupUI({ onComplete }: SetupUIProps) {
subprocess.on("error", () => {
// Silently ignore - user can still manually visit the URL shown above
});
} catch (openErr) {
} catch (_openErr) {
// If auto-open fails, user can still manually visit the URL
// This handles cases like missing opener commands in containers
}

View File

@@ -12,7 +12,7 @@ export function FeedbackDialog({ onSubmit, onCancel }: FeedbackDialogProps) {
const [feedbackText, setFeedbackText] = useState("");
const [error, setError] = useState("");
useInput((input, key) => {
useInput((_input, key) => {
if (key.escape) {
onCancel();
}

View File

@@ -0,0 +1,167 @@
import { describe, expect, test } from "bun:test";
import { formatSkillsForMemory, type Skill } from "../../agent/skills";
describe("Skills formatting", () => {
test("shows full metadata for small skill collections", () => {
const skills: Skill[] = [
{
id: "testing",
name: "Testing",
description: "Unit testing patterns and conventions",
path: "/test/.skills/testing/SKILL.md",
},
{
id: "deployment",
name: "Deployment",
description: "Deployment workflows and scripts",
path: "/test/.skills/deployment/SKILL.md",
},
];
const result = formatSkillsForMemory(skills, "/test/.skills");
// Should contain full metadata
expect(result).toContain("Available Skills:");
expect(result).toContain("### Testing");
expect(result).toContain("ID: `testing`");
expect(result).toContain("Description: Unit testing patterns");
expect(result).toContain("### Deployment");
// Should NOT contain tree format markers
expect(result).not.toContain("Note: Many skills available");
});
test("shows tree format when full metadata exceeds limit", () => {
// Create enough skills with long descriptions to exceed 20k chars
const skills: Skill[] = [];
const longDescription = "A".repeat(500); // 500 chars per description
for (let i = 0; i < 50; i++) {
skills.push({
id: `category-${i}/skill-${i}`,
name: `Skill ${i}`,
description: longDescription,
path: `/test/.skills/category-${i}/skill-${i}/SKILL.md`,
});
}
const result = formatSkillsForMemory(skills, "/test/.skills");
// Should contain tree format markers
expect(result).toContain("Note: Many skills available");
expect(result).toContain("showing directory structure only");
// Should NOT contain full metadata markers
expect(result).not.toContain("Available Skills:");
expect(result).not.toContain("Description:");
// Should show directory structure
expect(result).toContain("category-");
expect(result).toContain("skill-");
});
test("tree format shows nested directory structure", () => {
const skills: Skill[] = [];
const longDescription = "A".repeat(500);
// Create nested skills to exceed limit
for (let i = 0; i < 50; i++) {
skills.push({
id: `ai/tools/tool-${i}`,
name: `Tool ${i}`,
description: longDescription,
path: `/test/.skills/ai/tools/tool-${i}/SKILL.md`,
});
}
const result = formatSkillsForMemory(skills, "/test/.skills");
// Should show hierarchical structure
expect(result).toContain("ai/");
expect(result).toContain(" tools/");
expect(result).toContain(" tool-");
});
test("handles empty skill list", () => {
const result = formatSkillsForMemory([], "/test/.skills");
expect(result).toContain("Skills Directory: /test/.skills");
expect(result).toContain("[NO SKILLS AVAILABLE]");
});
test("tree format includes helper message", () => {
const skills: Skill[] = [];
const longDescription = "A".repeat(500);
for (let i = 0; i < 50; i++) {
skills.push({
id: `skill-${i}`,
name: `Skill ${i}`,
description: longDescription,
path: `/test/.skills/skill-${i}/SKILL.md`,
});
}
const result = formatSkillsForMemory(skills, "/test/.skills");
// Should include usage instructions
expect(result).toContain("Load it persistently into memory");
expect(result).toContain("Read");
expect(result).toContain("SKILL.md");
expect(result).toContain("preview without loading");
});
test("full format respects character limit boundary", () => {
// Create skills that are just under the limit
const skills: Skill[] = [];
// Each skill formatted is roughly 100 chars, so ~190 skills should be under 20k
for (let i = 0; i < 10; i++) {
skills.push({
id: `skill-${i}`,
name: `Skill ${i}`,
description: "Short description",
path: `/test/.skills/skill-${i}/SKILL.md`,
});
}
const result = formatSkillsForMemory(skills, "/test/.skills");
// With short descriptions, should still use full format
expect(result).toContain("Available Skills:");
expect(result.length).toBeLessThan(20000);
});
test("tree format groups skills by directory correctly", () => {
const skills: Skill[] = [];
const longDescription = "A".repeat(500);
for (let i = 0; i < 30; i++) {
skills.push({
id: `ai/agents/agent-${i}`,
name: `Agent ${i}`,
description: longDescription,
path: `/test/.skills/ai/agents/agent-${i}/SKILL.md`,
});
}
for (let i = 0; i < 30; i++) {
skills.push({
id: `development/patterns/pattern-${i}`,
name: `Pattern ${i}`,
description: longDescription,
path: `/test/.skills/development/patterns/pattern-${i}/SKILL.md`,
});
}
const result = formatSkillsForMemory(skills, "/test/.skills");
// Should show both top-level directories
expect(result).toContain("ai/");
expect(result).toContain("development/");
// Should show nested structure
expect(result).toContain(" agents/");
expect(result).toContain(" patterns/");
});
});