feat: add agent-scoped skills directory support (#692)
Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
@@ -1,10 +1,11 @@
|
||||
/**
|
||||
* Skills module - provides skill discovery and management functionality
|
||||
*
|
||||
* Skills are discovered from three sources (in order of priority):
|
||||
* Skills are discovered from four sources (in order of priority):
|
||||
* 1. Project skills: .skills/ in current directory (highest priority - overrides)
|
||||
* 2. Global skills: ~/.letta/skills/ for user's personal skills
|
||||
* 3. Bundled skills: embedded in package (lowest priority - defaults)
|
||||
* 2. Agent skills: ~/.letta/agents/{agent-id}/skills/ for agent-specific skills
|
||||
* 3. Global skills: ~/.letta/skills/ for user's personal skills
|
||||
* 4. Bundled skills: embedded in package (lowest priority - defaults)
|
||||
*/
|
||||
|
||||
import { existsSync } from "node:fs";
|
||||
@@ -34,7 +35,7 @@ function getBundledSkillsPath(): string {
|
||||
/**
|
||||
* Source of a skill (for display and override resolution)
|
||||
*/
|
||||
export type SkillSource = "bundled" | "global" | "project";
|
||||
export type SkillSource = "bundled" | "global" | "agent" | "project";
|
||||
|
||||
/**
|
||||
* Represents a skill that can be used by the agent
|
||||
@@ -91,6 +92,20 @@ export const GLOBAL_SKILLS_DIR = join(
|
||||
".letta/skills",
|
||||
);
|
||||
|
||||
/**
|
||||
* Get the agent-scoped skills directory for a specific agent
|
||||
* @param agentId - The Letta agent ID (e.g., "agent-abc123")
|
||||
* @returns Path like ~/.letta/agents/agent-abc123/skills/
|
||||
*/
|
||||
export function getAgentSkillsDir(agentId: string): string {
|
||||
return join(
|
||||
process.env.HOME || process.env.USERPROFILE || "~",
|
||||
".letta/agents",
|
||||
agentId,
|
||||
"skills",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Skills block character limit.
|
||||
* If formatted skills exceed this, fall back to compact tree format.
|
||||
@@ -142,19 +157,22 @@ async function discoverSkillsFromDir(
|
||||
}
|
||||
|
||||
/**
|
||||
* Discovers skills from all sources (bundled, global, project)
|
||||
* Discovers skills from all sources (bundled, global, agent, project)
|
||||
* Later sources override earlier ones with the same ID.
|
||||
*
|
||||
* Priority order (highest to lowest):
|
||||
* 1. Project skills (.skills/ in current directory)
|
||||
* 2. Global skills (~/.letta/skills/)
|
||||
* 3. Bundled skills (embedded in package)
|
||||
* 2. Agent skills (~/.letta/agents/{agent-id}/skills/)
|
||||
* 3. Global skills (~/.letta/skills/)
|
||||
* 4. Bundled skills (embedded in package)
|
||||
*
|
||||
* @param projectSkillsPath - The project skills directory (default: .skills in current directory)
|
||||
* @param agentId - Optional agent ID for agent-scoped skills
|
||||
* @returns A result containing discovered skills and any errors
|
||||
*/
|
||||
export async function discoverSkills(
|
||||
projectSkillsPath: string = join(process.cwd(), SKILLS_DIR),
|
||||
agentId?: string,
|
||||
): Promise<SkillDiscoveryResult> {
|
||||
const allErrors: SkillDiscoveryError[] = [];
|
||||
const skillsById = new Map<string, Skill>();
|
||||
@@ -172,7 +190,17 @@ export async function discoverSkills(
|
||||
skillsById.set(skill.id, skill);
|
||||
}
|
||||
|
||||
// 3. Add project skills (override global and bundled)
|
||||
// 3. Add agent skills if agentId provided (override global)
|
||||
if (agentId) {
|
||||
const agentSkillsDir = getAgentSkillsDir(agentId);
|
||||
const agentResult = await discoverSkillsFromDir(agentSkillsDir, "agent");
|
||||
allErrors.push(...agentResult.errors);
|
||||
for (const skill of agentResult.skills) {
|
||||
skillsById.set(skill.id, skill);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Add project skills (override all - highest priority)
|
||||
const projectResult = await discoverSkillsFromDir(
|
||||
projectSkillsPath,
|
||||
"project",
|
||||
@@ -390,14 +418,20 @@ function formatSkillsAsTree(skills: Skill[], skillsDirectory: string): string {
|
||||
* Formats discovered skills with full metadata
|
||||
* @param skills - Array of discovered skills
|
||||
* @param skillsDirectory - Absolute path to the skills directory
|
||||
* @param agentId - Optional agent ID for agent-scoped skills display
|
||||
* @returns Full metadata string representation
|
||||
*/
|
||||
function formatSkillsWithMetadata(
|
||||
skills: Skill[],
|
||||
skillsDirectory: string,
|
||||
agentId?: string,
|
||||
): string {
|
||||
let output = `Skills Directory: ${skillsDirectory}\n`;
|
||||
output += `Global Skills Directory: ${GLOBAL_SKILLS_DIR}\n\n`;
|
||||
output += `Global Skills Directory: ${GLOBAL_SKILLS_DIR}\n`;
|
||||
if (agentId) {
|
||||
output += `Agent Skills Directory: ${getAgentSkillsDir(agentId)}\n`;
|
||||
}
|
||||
output += "\n";
|
||||
|
||||
if (skills.length === 0) {
|
||||
return `${output}[NO SKILLS AVAILABLE]`;
|
||||
@@ -405,7 +439,7 @@ function formatSkillsWithMetadata(
|
||||
|
||||
output += "Available Skills:\n";
|
||||
output +=
|
||||
"(source: bundled = built-in to Letta Code, global = ~/.letta/skills/, project = .skills/)\n\n";
|
||||
"(source: bundled = built-in to Letta Code, global = shared across all agents on this machine (~/.letta/skills/), agent = skills specific to you (~/.letta/agents/{id}/skills/), project = current project only (.skills/))\n\n";
|
||||
|
||||
// Group skills by category if categories exist
|
||||
const categorized = new Map<string, Skill[]>();
|
||||
@@ -464,11 +498,13 @@ function formatSkill(skill: Skill): string {
|
||||
* 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
|
||||
* @param agentId - Optional agent ID for agent-scoped skills display
|
||||
* @returns Formatted string representation of skills
|
||||
*/
|
||||
export function formatSkillsForMemory(
|
||||
skills: Skill[],
|
||||
skillsDirectory: string,
|
||||
agentId?: string,
|
||||
): string {
|
||||
// Handle empty case
|
||||
if (skills.length === 0) {
|
||||
@@ -476,7 +512,7 @@ export function formatSkillsForMemory(
|
||||
}
|
||||
|
||||
// Try full metadata format first
|
||||
const fullFormat = formatSkillsWithMetadata(skills, skillsDirectory);
|
||||
const fullFormat = formatSkillsWithMetadata(skills, skillsDirectory, agentId);
|
||||
|
||||
// If within limit, use full format
|
||||
if (fullFormat.length <= SKILLS_BLOCK_CHAR_LIMIT) {
|
||||
@@ -565,8 +601,8 @@ export async function syncSkillsToAgent(
|
||||
skillsDirectory: string,
|
||||
options?: { skipIfUnchanged?: boolean },
|
||||
): Promise<{ synced: boolean; skills: Skill[] }> {
|
||||
// Discover skills from filesystem
|
||||
const { skills, errors } = await discoverSkills(skillsDirectory);
|
||||
// Discover skills from filesystem (including agent-scoped skills)
|
||||
const { skills, errors } = await discoverSkills(skillsDirectory, agentId);
|
||||
|
||||
if (errors.length > 0) {
|
||||
for (const error of errors) {
|
||||
@@ -575,7 +611,11 @@ export async function syncSkillsToAgent(
|
||||
}
|
||||
|
||||
// Format skills for memory block
|
||||
const formattedSkills = formatSkillsForMemory(skills, skillsDirectory);
|
||||
const formattedSkills = formatSkillsForMemory(
|
||||
skills,
|
||||
skillsDirectory,
|
||||
agentId,
|
||||
);
|
||||
|
||||
// Check if we can skip the update
|
||||
if (options?.skipIfUnchanged) {
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
discoverSkills,
|
||||
formatSkillsForMemory,
|
||||
GLOBAL_SKILLS_DIR,
|
||||
getAgentSkillsDir,
|
||||
getBundledSkills,
|
||||
SKILLS_DIR,
|
||||
} from "../../agent/skills";
|
||||
@@ -269,10 +270,17 @@ function hasAdditionalFiles(skillMdPath: string): boolean {
|
||||
/**
|
||||
* Read skill content from file or bundled source
|
||||
* Returns both content and the path to the SKILL.md file
|
||||
*
|
||||
* Search order (highest priority first):
|
||||
* 1. Project skills (.skills/)
|
||||
* 2. Agent skills (~/.letta/agents/{id}/skills/)
|
||||
* 3. Global skills (~/.letta/skills/)
|
||||
* 4. Bundled skills
|
||||
*/
|
||||
async function readSkillContent(
|
||||
skillId: string,
|
||||
skillsDir: string,
|
||||
agentId?: string,
|
||||
): Promise<{ content: string; path: string }> {
|
||||
// 1. Check bundled skills first (they have a path now)
|
||||
const bundledSkills = await getBundledSkills();
|
||||
@@ -295,7 +303,22 @@ async function readSkillContent(
|
||||
// Not in global, continue
|
||||
}
|
||||
|
||||
// 3. Try project skills directory
|
||||
// 3. Try agent skills directory (if agentId provided)
|
||||
if (agentId) {
|
||||
const agentSkillPath = join(
|
||||
getAgentSkillsDir(agentId),
|
||||
skillId,
|
||||
"SKILL.md",
|
||||
);
|
||||
try {
|
||||
const content = await readFile(agentSkillPath, "utf-8");
|
||||
return { content, path: agentSkillPath };
|
||||
} catch {
|
||||
// Not in agent dir, continue
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Try project skills directory
|
||||
const projectSkillPath = join(skillsDir, skillId, "SKILL.md");
|
||||
try {
|
||||
const content = await readFile(projectSkillPath, "utf-8");
|
||||
@@ -376,8 +399,8 @@ export async function skill(args: SkillArgs): Promise<SkillResult> {
|
||||
if (command === "refresh") {
|
||||
const skillsDir = await getResolvedSkillsDir(client, agentId);
|
||||
|
||||
// Discover skills from directory
|
||||
const { skills, errors } = await discoverSkills(skillsDir);
|
||||
// Discover skills from directory (including agent-scoped skills)
|
||||
const { skills, errors } = await discoverSkills(skillsDir, agentId);
|
||||
|
||||
// Log any errors
|
||||
if (errors.length > 0) {
|
||||
@@ -389,7 +412,7 @@ export async function skill(args: SkillArgs): Promise<SkillResult> {
|
||||
}
|
||||
|
||||
// Format and update the skills block
|
||||
const formattedSkills = formatSkillsForMemory(skills, skillsDir);
|
||||
const formattedSkills = formatSkillsForMemory(skills, skillsDir, agentId);
|
||||
await updateBlock(client, agentId, "skills", formattedSkills);
|
||||
|
||||
const successMsg =
|
||||
@@ -435,7 +458,7 @@ export async function skill(args: SkillArgs): Promise<SkillResult> {
|
||||
|
||||
try {
|
||||
const { content: skillContent, path: skillPath } =
|
||||
await readSkillContent(skillId, skillsDir);
|
||||
await readSkillContent(skillId, skillsDir, agentId);
|
||||
|
||||
// Replace placeholder if this is the first skill (support old and new formats)
|
||||
if (
|
||||
@@ -613,6 +636,7 @@ export async function skill(args: SkillArgs): Promise<SkillResult> {
|
||||
export async function preloadSkillsContent(
|
||||
skillIds: string[],
|
||||
skillsDir: string,
|
||||
agentId?: string,
|
||||
): Promise<string> {
|
||||
if (skillIds.length === 0) {
|
||||
return "No skills currently loaded.";
|
||||
@@ -625,6 +649,7 @@ export async function preloadSkillsContent(
|
||||
const { content: skillContent, path: skillPath } = await readSkillContent(
|
||||
skillId,
|
||||
skillsDir,
|
||||
agentId,
|
||||
);
|
||||
|
||||
const skillDir = dirname(skillPath);
|
||||
|
||||
Reference in New Issue
Block a user