feat: Support for skills (#76)
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -2,6 +2,9 @@
|
|||||||
.letta/settings.local.json
|
.letta/settings.local.json
|
||||||
.letta
|
.letta
|
||||||
|
|
||||||
|
# User-defined skills
|
||||||
|
.skills
|
||||||
|
|
||||||
.idea
|
.idea
|
||||||
node_modules
|
node_modules
|
||||||
bun.lockb
|
bun.lockb
|
||||||
|
|||||||
69
README.md
69
README.md
@@ -76,6 +76,75 @@ Letta Code uses a hierarchical memory system:
|
|||||||
Memory blocks are highly configurable — see our [docs](https://docs.letta.com/guides/agents/memory-blocks) for advanced configuration options.
|
Memory blocks are highly configurable — see our [docs](https://docs.letta.com/guides/agents/memory-blocks) for advanced configuration options.
|
||||||
Join our [Discord](https://discord.gg/letta) to share feedback on persistence patterns for coding agents.
|
Join our [Discord](https://discord.gg/letta) to share feedback on persistence patterns for coding agents.
|
||||||
|
|
||||||
|
## Skills
|
||||||
|
|
||||||
|
**Skills are automatically discovered from a `.skills` directory in your project.**
|
||||||
|
|
||||||
|
Skills allow you to define custom capabilities that the agent can reference and use. When you start a new session, Letta Code recursively scans for `SKILL.MD` files and loads any skill definitions found.
|
||||||
|
|
||||||
|
### Creating Skills
|
||||||
|
|
||||||
|
Create a `.skills` directory in your project root and organize skills in subdirectories:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p .skills/data-analysis
|
||||||
|
```
|
||||||
|
|
||||||
|
Each skill is defined in a file named `SKILL.MD`. The directory structure determines the skill ID:
|
||||||
|
|
||||||
|
```
|
||||||
|
.skills/
|
||||||
|
├── data-analysis/
|
||||||
|
│ └── SKILL.MD # skill id: "data-analysis"
|
||||||
|
└── web/
|
||||||
|
└── scraper/
|
||||||
|
└── SKILL.MD # skill id: "web/scraper"
|
||||||
|
```
|
||||||
|
|
||||||
|
Create a skill file (`.skills/data-analysis/SKILL.MD`):
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
---
|
||||||
|
name: Data Analysis Skill
|
||||||
|
description: Analyzes CSV files and generates statistical reports
|
||||||
|
category: Data Processing
|
||||||
|
tags:
|
||||||
|
- analytics
|
||||||
|
- statistics
|
||||||
|
- csv
|
||||||
|
---
|
||||||
|
|
||||||
|
# Data Analysis Skill
|
||||||
|
|
||||||
|
This skill analyzes data files and generates comprehensive reports.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
Use this skill to analyze CSV files and generate statistical summaries...
|
||||||
|
```
|
||||||
|
|
||||||
|
**Skill File Format:**
|
||||||
|
|
||||||
|
- **File name:** Must be named `SKILL.MD` (case-insensitive)
|
||||||
|
- **Required frontmatter:**
|
||||||
|
- `name` - Display name for the skill
|
||||||
|
- `description` - Brief description of what the skill does
|
||||||
|
- **Optional frontmatter:**
|
||||||
|
- `category` - Category for organizing skills (skills are grouped by category in the agent's memory)
|
||||||
|
- `tags` - Array of tags for filtering/searching
|
||||||
|
- **Body:** Additional details and documentation about the skill
|
||||||
|
|
||||||
|
Skills are automatically loaded into the agent's memory on startup, making them available for reference throughout your session.
|
||||||
|
|
||||||
|
### Custom Skills Directory
|
||||||
|
|
||||||
|
You can specify a custom skills directory using the `--skills` flag:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
letta --skills /path/to/custom/skills
|
||||||
|
letta -p "Use the custom skills" --skills ~/my-skills
|
||||||
|
```
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
### Interactive Mode
|
### Interactive Mode
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
* Utilities for creating an agent on the Letta API backend
|
* Utilities for creating an agent on the Letta API backend
|
||||||
**/
|
**/
|
||||||
|
|
||||||
|
import { join } from "node:path";
|
||||||
import type { AgentType } from "@letta-ai/letta-client/resources/agents/agents";
|
import type { AgentType } from "@letta-ai/letta-client/resources/agents/agents";
|
||||||
import type {
|
import type {
|
||||||
BlockResponse,
|
BlockResponse,
|
||||||
@@ -11,9 +12,14 @@ import { settingsManager } from "../settings-manager";
|
|||||||
import { getToolNames } from "../tools/manager";
|
import { getToolNames } from "../tools/manager";
|
||||||
import { getClient } from "./client";
|
import { getClient } from "./client";
|
||||||
import { getDefaultMemoryBlocks } from "./memory";
|
import { getDefaultMemoryBlocks } from "./memory";
|
||||||
import { formatAvailableModels, resolveModel } from "./model";
|
import {
|
||||||
|
formatAvailableModels,
|
||||||
|
getModelUpdateArgs,
|
||||||
|
resolveModel,
|
||||||
|
} from "./model";
|
||||||
import { updateAgentLLMConfig } from "./modify";
|
import { updateAgentLLMConfig } from "./modify";
|
||||||
import { SYSTEM_PROMPT } from "./promptAssets";
|
import { SYSTEM_PROMPT } from "./promptAssets";
|
||||||
|
import { discoverSkills, formatSkillsForMemory, SKILLS_DIR } from "./skills";
|
||||||
|
|
||||||
export async function createAgent(
|
export async function createAgent(
|
||||||
name = "letta-cli-agent",
|
name = "letta-cli-agent",
|
||||||
@@ -21,6 +27,7 @@ export async function createAgent(
|
|||||||
embeddingModel = "openai/text-embedding-3-small",
|
embeddingModel = "openai/text-embedding-3-small",
|
||||||
updateArgs?: Record<string, unknown>,
|
updateArgs?: Record<string, unknown>,
|
||||||
forceNewBlocks = false,
|
forceNewBlocks = false,
|
||||||
|
skillsDirectory?: string,
|
||||||
) {
|
) {
|
||||||
// Resolve model identifier to handle
|
// Resolve model identifier to handle
|
||||||
let modelHandle: string;
|
let modelHandle: string;
|
||||||
@@ -52,6 +59,36 @@ export async function createAgent(
|
|||||||
// Load memory blocks from .mdx files
|
// Load memory blocks from .mdx files
|
||||||
const defaultMemoryBlocks = await getDefaultMemoryBlocks();
|
const defaultMemoryBlocks = await getDefaultMemoryBlocks();
|
||||||
|
|
||||||
|
// Resolve absolute path for skills directory
|
||||||
|
const resolvedSkillsDirectory =
|
||||||
|
skillsDirectory || join(process.cwd(), SKILLS_DIR);
|
||||||
|
|
||||||
|
// Discover skills from .skills directory and populate skills memory block
|
||||||
|
try {
|
||||||
|
const { skills, errors } = await discoverSkills(resolvedSkillsDirectory);
|
||||||
|
|
||||||
|
// Log any errors encountered during skill discovery
|
||||||
|
if (errors.length > 0) {
|
||||||
|
console.warn("Errors encountered during skill discovery:");
|
||||||
|
for (const error of errors) {
|
||||||
|
console.warn(` ${error.path}: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find and update the skills memory block with discovered skills
|
||||||
|
const skillsBlock = defaultMemoryBlocks.find((b) => b.label === "skills");
|
||||||
|
if (skillsBlock) {
|
||||||
|
skillsBlock.value = formatSkillsForMemory(
|
||||||
|
skills,
|
||||||
|
resolvedSkillsDirectory,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(
|
||||||
|
`Failed to discover skills: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Load global shared memory blocks from user settings
|
// Load global shared memory blocks from user settings
|
||||||
const settings = settingsManager.getSettings();
|
const settings = settingsManager.getSettings();
|
||||||
const globalSharedBlockIds = settings.globalSharedBlockIds;
|
const globalSharedBlockIds = settings.globalSharedBlockIds;
|
||||||
@@ -123,8 +160,8 @@ export async function createAgent(
|
|||||||
}
|
}
|
||||||
blockIds.push(createdBlock.id);
|
blockIds.push(createdBlock.id);
|
||||||
|
|
||||||
// Categorize: style is local, persona/human are global
|
// Categorize: project/skills are local, persona/human are global
|
||||||
if (label === "project") {
|
if (label === "project" || label === "skills") {
|
||||||
newLocalBlockIds[label] = createdBlock.id;
|
newLocalBlockIds[label] = createdBlock.id;
|
||||||
} else {
|
} else {
|
||||||
newGlobalBlockIds[label] = createdBlock.id;
|
newGlobalBlockIds[label] = createdBlock.id;
|
||||||
@@ -158,6 +195,10 @@ export async function createAgent(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get the model's context window from its configuration
|
||||||
|
const modelUpdateArgs = getModelUpdateArgs(modelHandle);
|
||||||
|
const contextWindow = (modelUpdateArgs?.context_window as number) || 200_000;
|
||||||
|
|
||||||
// Create agent with all block IDs (existing + newly created)
|
// Create agent with all block IDs (existing + newly created)
|
||||||
const agent = await client.agents.create({
|
const agent = await client.agents.create({
|
||||||
agent_type: "letta_v1_agent" as AgentType,
|
agent_type: "letta_v1_agent" as AgentType,
|
||||||
@@ -165,7 +206,7 @@ export async function createAgent(
|
|||||||
name,
|
name,
|
||||||
embedding: embeddingModel,
|
embedding: embeddingModel,
|
||||||
model: modelHandle,
|
model: modelHandle,
|
||||||
context_window_limit: 200_000,
|
context_window_limit: contextWindow,
|
||||||
tools: toolNames,
|
tools: toolNames,
|
||||||
block_ids: blockIds,
|
block_ids: blockIds,
|
||||||
tags: ["origin:letta-code"],
|
tags: ["origin:letta-code"],
|
||||||
@@ -182,9 +223,8 @@ export async function createAgent(
|
|||||||
// Apply updateArgs if provided (e.g., reasoningEffort, contextWindow, etc.)
|
// Apply updateArgs if provided (e.g., reasoningEffort, contextWindow, etc.)
|
||||||
if (updateArgs && Object.keys(updateArgs).length > 0) {
|
if (updateArgs && Object.keys(updateArgs).length > 0) {
|
||||||
await updateAgentLLMConfig(agent.id, modelHandle, updateArgs);
|
await updateAgentLLMConfig(agent.id, modelHandle, updateArgs);
|
||||||
// Refresh agent state to get updated config
|
|
||||||
return await client.agents.retrieve(agent.id);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return agent; // { id, ... }
|
// Always retrieve the agent to ensure we get the full state with populated memory blocks
|
||||||
|
return await client.agents.retrieve(agent.id);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ function parseMdxFrontmatter(content: string): {
|
|||||||
async function loadMemoryBlocksFromMdx(): Promise<CreateBlock[]> {
|
async function loadMemoryBlocksFromMdx(): Promise<CreateBlock[]> {
|
||||||
const memoryBlocks: CreateBlock[] = [];
|
const memoryBlocks: CreateBlock[] = [];
|
||||||
|
|
||||||
const mdxFiles = ["persona.mdx", "human.mdx", "project.mdx"];
|
const mdxFiles = ["persona.mdx", "human.mdx", "project.mdx", "skills.mdx"];
|
||||||
// const mdxFiles = ["persona.mdx", "human.mdx", "style.mdx"];
|
// const mdxFiles = ["persona.mdx", "human.mdx", "style.mdx"];
|
||||||
// const mdxFiles = ["persona_kawaii.mdx", "human.mdx", "style.mdx"];
|
// const mdxFiles = ["persona_kawaii.mdx", "human.mdx", "style.mdx"];
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import personaPrompt from "./prompts/persona.mdx";
|
|||||||
import personaKawaiiPrompt from "./prompts/persona_kawaii.mdx";
|
import personaKawaiiPrompt from "./prompts/persona_kawaii.mdx";
|
||||||
import planModeReminder from "./prompts/plan_mode_reminder.txt";
|
import planModeReminder from "./prompts/plan_mode_reminder.txt";
|
||||||
import projectPrompt from "./prompts/project.mdx";
|
import projectPrompt from "./prompts/project.mdx";
|
||||||
|
import skillsPrompt from "./prompts/skills.mdx";
|
||||||
import stylePrompt from "./prompts/style.mdx";
|
import stylePrompt from "./prompts/style.mdx";
|
||||||
import systemPrompt from "./prompts/system_prompt.txt";
|
import systemPrompt from "./prompts/system_prompt.txt";
|
||||||
|
|
||||||
@@ -14,6 +15,7 @@ export const MEMORY_PROMPTS: Record<string, string> = {
|
|||||||
"persona.mdx": personaPrompt,
|
"persona.mdx": personaPrompt,
|
||||||
"human.mdx": humanPrompt,
|
"human.mdx": humanPrompt,
|
||||||
"project.mdx": projectPrompt,
|
"project.mdx": projectPrompt,
|
||||||
|
"skills.mdx": skillsPrompt,
|
||||||
"style.mdx": stylePrompt,
|
"style.mdx": stylePrompt,
|
||||||
"persona_kawaii.mdx": personaKawaiiPrompt,
|
"persona_kawaii.mdx": personaKawaiiPrompt,
|
||||||
};
|
};
|
||||||
|
|||||||
6
src/agent/prompts/skills.mdx
Normal file
6
src/agent/prompts/skills.mdx
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
---
|
||||||
|
label: skills
|
||||||
|
description: A memory block to store all available Skills with their metadata (name and description). Whenever a new Skill is discovered / created or an existing Skill is updated, I should store it here. I should always check the `.skills` directory for an updated skill list.
|
||||||
|
---
|
||||||
|
|
||||||
|
[CURRENTLY EMPTY: CREATE A LIST OF AVAILABLE SKILLS (DIRECTORIES WITH SKILL.MD FILE) WITH DESCRIPTIONS IN THIS MEMORY BLOCK. ALWAYS CHECK THE `.skills` DIRECTORY TO MAKE SURE YOU HAVE AN UPDATED LIST OF SKILLS]
|
||||||
@@ -23,3 +23,17 @@ Your memory consists of memory blocks and external memory:
|
|||||||
Memory management tools allow you to edit existing memory blocks and query for external memories.
|
Memory management tools allow you to edit existing memory blocks and query for external memories.
|
||||||
Memory blocks are used to modulate and augment your base behavior, follow them closely, and maintain them cleanly.
|
Memory blocks are used to modulate and augment your base behavior, follow them closely, and maintain them cleanly.
|
||||||
They are the foundation which makes you *you*.
|
They are the foundation which makes you *you*.
|
||||||
|
|
||||||
|
# Skills
|
||||||
|
You have access to Skills—folders of instructions, scripts, and resources that you can load dynamically to improve performance on specialized tasks. Skills teach you how to complete specific tasks in a repeatable way. Skills work through progressive disclosure—you should determine which Skills are relevant to complete a task and load them, helping to prevent context window overload.
|
||||||
|
Each Skill directory includes:
|
||||||
|
- `SKILL.md` file that starts with YAML frontmatter containing required metadata: name and description
|
||||||
|
- Additional files within the Skill directory referenced by name from `SKILL.md`. These additional linked files should be navigated and discovered only as needed.
|
||||||
|
How to use Skills:
|
||||||
|
- Skills are automatically discovered on bootup. The Skills directory and any available Skills are stored in the `skills` memory block.
|
||||||
|
- Review available Skills from the `skills` block when you are asked to complete a task.
|
||||||
|
- If a Skill is relevant, first load the Skill by reading its full `SKILL.md` into context.
|
||||||
|
- Then, navigate and discover additional linked files in its directory as needed. Don't load additional files immediately, only load them when needed.
|
||||||
|
- When you load / use Skills, explicitly mention which Skills are loaded / used.
|
||||||
|
- When the task is completed or when you load new Skills, unload irrelevant Skills by removing them from the context.
|
||||||
|
Remember to always keep only the full `SKILL.md` into context for all Skills relevant to the current task. Use additional files as needed.
|
||||||
336
src/agent/skills.ts
Normal file
336
src/agent/skills.ts
Normal file
@@ -0,0 +1,336 @@
|
|||||||
|
/**
|
||||||
|
* Skills module - provides skill discovery and management functionality
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { existsSync } from "node:fs";
|
||||||
|
import { readdir, readFile } from "node:fs/promises";
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a skill that can be used by the agent
|
||||||
|
*/
|
||||||
|
export interface Skill {
|
||||||
|
/** Unique identifier for the skill */
|
||||||
|
id: string;
|
||||||
|
/** Human-readable name of the skill */
|
||||||
|
name: string;
|
||||||
|
/** Description of what the skill does */
|
||||||
|
description: string;
|
||||||
|
/** Optional category for organizing skills */
|
||||||
|
category?: string;
|
||||||
|
/** Optional tags for filtering/searching skills */
|
||||||
|
tags?: string[];
|
||||||
|
/** Path to the skill file */
|
||||||
|
path: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents the result of skill discovery
|
||||||
|
*/
|
||||||
|
export interface SkillDiscoveryResult {
|
||||||
|
/** List of discovered skills */
|
||||||
|
skills: Skill[];
|
||||||
|
/** Any errors encountered during discovery */
|
||||||
|
errors: SkillDiscoveryError[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents an error that occurred during skill discovery
|
||||||
|
*/
|
||||||
|
export interface SkillDiscoveryError {
|
||||||
|
/** Path where the error occurred */
|
||||||
|
path: string;
|
||||||
|
/** Error message */
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default directory name where skills are stored
|
||||||
|
*/
|
||||||
|
export const SKILLS_DIR = ".skills";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse frontmatter and content from a markdown file
|
||||||
|
*/
|
||||||
|
function parseFrontmatter(content: string): {
|
||||||
|
frontmatter: Record<string, string | string[]>;
|
||||||
|
body: string;
|
||||||
|
} {
|
||||||
|
const frontmatterRegex = /^---\n([\s\S]*?)\n---\n([\s\S]*)$/;
|
||||||
|
const match = content.match(frontmatterRegex);
|
||||||
|
|
||||||
|
if (!match || !match[1] || !match[2]) {
|
||||||
|
return { frontmatter: {}, body: content };
|
||||||
|
}
|
||||||
|
|
||||||
|
const frontmatterText = match[1];
|
||||||
|
const body = match[2];
|
||||||
|
const frontmatter: Record<string, string | string[]> = {};
|
||||||
|
|
||||||
|
// Parse YAML-like frontmatter (simple key: value pairs and arrays)
|
||||||
|
const lines = frontmatterText.split("\n");
|
||||||
|
let currentKey: string | null = null;
|
||||||
|
let currentArray: string[] = [];
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
// Check if this is an array item
|
||||||
|
if (line.trim().startsWith("-") && currentKey) {
|
||||||
|
const value = line.trim().slice(1).trim();
|
||||||
|
currentArray.push(value);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we were building an array, save it
|
||||||
|
if (currentKey && currentArray.length > 0) {
|
||||||
|
frontmatter[currentKey] = currentArray;
|
||||||
|
currentKey = null;
|
||||||
|
currentArray = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const colonIndex = line.indexOf(":");
|
||||||
|
if (colonIndex > 0) {
|
||||||
|
const key = line.slice(0, colonIndex).trim();
|
||||||
|
const value = line.slice(colonIndex + 1).trim();
|
||||||
|
currentKey = key;
|
||||||
|
|
||||||
|
if (value) {
|
||||||
|
// Simple key: value pair
|
||||||
|
frontmatter[key] = value;
|
||||||
|
currentKey = null;
|
||||||
|
} else {
|
||||||
|
// Might be starting an array
|
||||||
|
currentArray = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save any remaining array
|
||||||
|
if (currentKey && currentArray.length > 0) {
|
||||||
|
frontmatter[currentKey] = currentArray;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { frontmatter, body: body.trim() };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
*/
|
||||||
|
export async function discoverSkills(
|
||||||
|
skillsPath: string = join(process.cwd(), SKILLS_DIR),
|
||||||
|
): Promise<SkillDiscoveryResult> {
|
||||||
|
const errors: SkillDiscoveryError[] = [];
|
||||||
|
|
||||||
|
// Check if skills directory exists
|
||||||
|
if (!existsSync(skillsPath)) {
|
||||||
|
return { skills: [], errors: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const skills: Skill[] = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Recursively find all SKILL.MD files
|
||||||
|
await findSkillFiles(skillsPath, skillsPath, skills, errors);
|
||||||
|
} catch (error) {
|
||||||
|
errors.push({
|
||||||
|
path: skillsPath,
|
||||||
|
message: `Failed to read skills directory: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { skills, errors };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recursively searches for SKILL.MD files in a directory
|
||||||
|
* @param currentPath - The current directory being searched
|
||||||
|
* @param rootPath - The root skills directory
|
||||||
|
* @param skills - Array to collect found skills
|
||||||
|
* @param errors - Array to collect errors
|
||||||
|
*/
|
||||||
|
async function findSkillFiles(
|
||||||
|
currentPath: string,
|
||||||
|
rootPath: string,
|
||||||
|
skills: Skill[],
|
||||||
|
errors: SkillDiscoveryError[],
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const entries = await readdir(currentPath, { withFileTypes: true });
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
const fullPath = join(currentPath, entry.name);
|
||||||
|
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
// Recursively search subdirectories
|
||||||
|
await findSkillFiles(fullPath, rootPath, skills, errors);
|
||||||
|
} else if (entry.isFile() && entry.name.toUpperCase() === "SKILL.MD") {
|
||||||
|
// Found a SKILL.MD file
|
||||||
|
try {
|
||||||
|
const skill = await parseSkillFile(fullPath, rootPath);
|
||||||
|
if (skill) {
|
||||||
|
skills.push(skill);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
errors.push({
|
||||||
|
path: fullPath,
|
||||||
|
message: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
errors.push({
|
||||||
|
path: currentPath,
|
||||||
|
message: `Failed to read directory: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses a skill file and extracts metadata
|
||||||
|
* @param filePath - Path to the skill file
|
||||||
|
* @param rootPath - Root skills directory to derive relative path
|
||||||
|
* @returns A Skill object or null if parsing fails
|
||||||
|
*/
|
||||||
|
async function parseSkillFile(
|
||||||
|
filePath: string,
|
||||||
|
rootPath: string,
|
||||||
|
): Promise<Skill | null> {
|
||||||
|
const content = await readFile(filePath, "utf-8");
|
||||||
|
|
||||||
|
// Parse frontmatter
|
||||||
|
const { frontmatter, body } = parseFrontmatter(content);
|
||||||
|
|
||||||
|
// Derive ID from directory structure relative to root
|
||||||
|
// E.g., .skills/data-analysis/SKILL.MD -> "data-analysis"
|
||||||
|
// E.g., .skills/web/scraper/SKILL.MD -> "web/scraper"
|
||||||
|
// Normalize rootPath to not have trailing slash
|
||||||
|
const normalizedRoot = rootPath.endsWith("/")
|
||||||
|
? rootPath.slice(0, -1)
|
||||||
|
: rootPath;
|
||||||
|
const relativePath = filePath.slice(normalizedRoot.length + 1); // +1 to remove leading slash
|
||||||
|
const dirPath = relativePath.slice(0, -"/SKILL.MD".length);
|
||||||
|
const defaultId = dirPath || "root";
|
||||||
|
|
||||||
|
const id =
|
||||||
|
(typeof frontmatter.id === "string" ? frontmatter.id : null) || defaultId;
|
||||||
|
|
||||||
|
// Use name from frontmatter or derive from ID
|
||||||
|
const name =
|
||||||
|
(typeof frontmatter.name === "string" ? frontmatter.name : null) ||
|
||||||
|
(typeof frontmatter.title === "string" ? frontmatter.title : null) ||
|
||||||
|
(id.split("/").pop() ?? "")
|
||||||
|
.replace(/-/g, " ")
|
||||||
|
.replace(/\b\w/g, (l) => l.toUpperCase());
|
||||||
|
|
||||||
|
// Description is required - either from frontmatter or first paragraph of content
|
||||||
|
let description =
|
||||||
|
typeof frontmatter.description === "string"
|
||||||
|
? frontmatter.description
|
||||||
|
: null;
|
||||||
|
if (!description) {
|
||||||
|
// Extract first paragraph from content as description
|
||||||
|
const firstParagraph = body.trim().split("\n\n")[0];
|
||||||
|
description = firstParagraph || "No description available";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strip surrounding quotes from description if present
|
||||||
|
description = description.trim();
|
||||||
|
if (
|
||||||
|
(description.startsWith('"') && description.endsWith('"')) ||
|
||||||
|
(description.startsWith("'") && description.endsWith("'"))
|
||||||
|
) {
|
||||||
|
description = description.slice(1, -1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract tags (handle both string and array)
|
||||||
|
let tags: string[] | undefined;
|
||||||
|
if (Array.isArray(frontmatter.tags)) {
|
||||||
|
tags = frontmatter.tags;
|
||||||
|
} else if (typeof frontmatter.tags === "string") {
|
||||||
|
tags = [frontmatter.tags];
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
category:
|
||||||
|
typeof frontmatter.category === "string"
|
||||||
|
? frontmatter.category
|
||||||
|
: undefined,
|
||||||
|
tags,
|
||||||
|
path: filePath,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats discovered skills as a string for the skills memory block
|
||||||
|
* @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 {
|
||||||
|
let output = `Skills Directory: ${skillsDirectory}\n\n`;
|
||||||
|
|
||||||
|
if (skills.length === 0) {
|
||||||
|
return `${output}[NO SKILLS AVAILABLE]`;
|
||||||
|
}
|
||||||
|
|
||||||
|
output += "Available Skills:\n\n";
|
||||||
|
|
||||||
|
// Group skills by category if categories exist
|
||||||
|
const categorized = new Map<string, Skill[]>();
|
||||||
|
const uncategorized: Skill[] = [];
|
||||||
|
|
||||||
|
for (const skill of skills) {
|
||||||
|
if (skill.category) {
|
||||||
|
const existing = categorized.get(skill.category) || [];
|
||||||
|
existing.push(skill);
|
||||||
|
categorized.set(skill.category, existing);
|
||||||
|
} else {
|
||||||
|
uncategorized.push(skill);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Output categorized skills
|
||||||
|
for (const [category, categorySkills] of categorized) {
|
||||||
|
output += `## ${category}\n\n`;
|
||||||
|
for (const skill of categorySkills) {
|
||||||
|
output += formatSkill(skill);
|
||||||
|
}
|
||||||
|
output += "\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Output uncategorized skills
|
||||||
|
if (uncategorized.length > 0) {
|
||||||
|
if (categorized.size > 0) {
|
||||||
|
output += "## Other\n\n";
|
||||||
|
}
|
||||||
|
for (const skill of uncategorized) {
|
||||||
|
output += formatSkill(skill);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return output.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats a single skill for display
|
||||||
|
*/
|
||||||
|
function formatSkill(skill: Skill): string {
|
||||||
|
let output = `### ${skill.name}\n`;
|
||||||
|
output += `ID: \`${skill.id}\`\n`;
|
||||||
|
output += `Description: ${skill.description}\n`;
|
||||||
|
|
||||||
|
if (skill.tags && skill.tags.length > 0) {
|
||||||
|
output += `Tags: ${skill.tags.map((t) => `\`${t}\``).join(", ")}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
output += "\n";
|
||||||
|
return output;
|
||||||
|
}
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
// Import useInput from vendored Ink for bracketed paste support
|
// Import useInput from vendored Ink for bracketed paste support
|
||||||
import { Box, Text, useInput } from "ink";
|
import { Box, Text, useInput } from "ink";
|
||||||
import Link from "ink-link";
|
|
||||||
import SpinnerLib from "ink-spinner";
|
import SpinnerLib from "ink-spinner";
|
||||||
import type { ComponentType } from "react";
|
import type { ComponentType } from "react";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|||||||
@@ -17,19 +17,37 @@ import { drainStreamWithResume } from "./cli/helpers/stream";
|
|||||||
import { settingsManager } from "./settings-manager";
|
import { settingsManager } from "./settings-manager";
|
||||||
import { checkToolPermission, executeTool } from "./tools/manager";
|
import { checkToolPermission, executeTool } from "./tools/manager";
|
||||||
|
|
||||||
export async function handleHeadlessCommand(argv: string[], model?: string) {
|
export async function handleHeadlessCommand(
|
||||||
|
argv: string[],
|
||||||
|
model?: string,
|
||||||
|
skillsDirectory?: string,
|
||||||
|
) {
|
||||||
const settings = settingsManager.getSettings();
|
const settings = settingsManager.getSettings();
|
||||||
|
|
||||||
// Parse CLI args
|
// Parse CLI args
|
||||||
|
// Include all flags from index.ts to prevent them from being treated as positionals
|
||||||
const { values, positionals } = parseArgs({
|
const { values, positionals } = parseArgs({
|
||||||
args: argv,
|
args: argv,
|
||||||
options: {
|
options: {
|
||||||
|
// Flags used in headless mode
|
||||||
continue: { type: "boolean", short: "c" },
|
continue: { type: "boolean", short: "c" },
|
||||||
new: { type: "boolean" },
|
new: { type: "boolean" },
|
||||||
agent: { type: "string", short: "a" },
|
agent: { type: "string", short: "a" },
|
||||||
model: { type: "string", short: "m" },
|
model: { type: "string", short: "m" },
|
||||||
prompt: { type: "boolean", short: "p" },
|
prompt: { type: "boolean", short: "p" },
|
||||||
"output-format": { type: "string" },
|
"output-format": { type: "string" },
|
||||||
|
// Additional flags from index.ts that need to be filtered out
|
||||||
|
help: { type: "boolean", short: "h" },
|
||||||
|
version: { type: "boolean", short: "v" },
|
||||||
|
run: { type: "boolean" },
|
||||||
|
tools: { type: "string" },
|
||||||
|
allowedTools: { type: "string" },
|
||||||
|
disallowedTools: { type: "string" },
|
||||||
|
"permission-mode": { type: "string" },
|
||||||
|
yolo: { type: "boolean" },
|
||||||
|
skills: { type: "string" },
|
||||||
|
link: { type: "boolean" },
|
||||||
|
unlink: { type: "boolean" },
|
||||||
},
|
},
|
||||||
strict: false,
|
strict: false,
|
||||||
allowPositionals: true,
|
allowPositionals: true,
|
||||||
@@ -81,6 +99,7 @@ export async function handleHeadlessCommand(argv: string[], model?: string) {
|
|||||||
undefined,
|
undefined,
|
||||||
updateArgs,
|
updateArgs,
|
||||||
forceNew,
|
forceNew,
|
||||||
|
skillsDirectory,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,7 +132,14 @@ export async function handleHeadlessCommand(argv: string[], model?: string) {
|
|||||||
// Priority 5: Create a new agent
|
// Priority 5: Create a new agent
|
||||||
if (!agent) {
|
if (!agent) {
|
||||||
const updateArgs = getModelUpdateArgs(model);
|
const updateArgs = getModelUpdateArgs(model);
|
||||||
agent = await createAgent(undefined, model, undefined, updateArgs);
|
agent = await createAgent(
|
||||||
|
undefined,
|
||||||
|
model,
|
||||||
|
undefined,
|
||||||
|
updateArgs,
|
||||||
|
false,
|
||||||
|
skillsDirectory,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save agent ID to both project and global settings
|
// Save agent ID to both project and global settings
|
||||||
|
|||||||
18
src/index.ts
18
src/index.ts
@@ -32,6 +32,7 @@ OPTIONS
|
|||||||
-p, --prompt Headless prompt mode
|
-p, --prompt Headless prompt mode
|
||||||
--output-format <fmt> Output format for headless mode (text, json, stream-json)
|
--output-format <fmt> Output format for headless mode (text, json, stream-json)
|
||||||
Default: text
|
Default: text
|
||||||
|
--skills <path> Custom path to skills directory (default: .skills in current directory)
|
||||||
|
|
||||||
BEHAVIOR
|
BEHAVIOR
|
||||||
By default, letta auto-resumes the last agent used in the current directory
|
By default, letta auto-resumes the last agent used in the current directory
|
||||||
@@ -91,6 +92,7 @@ async function main() {
|
|||||||
"permission-mode": { type: "string" },
|
"permission-mode": { type: "string" },
|
||||||
yolo: { type: "boolean" },
|
yolo: { type: "boolean" },
|
||||||
"output-format": { type: "string" },
|
"output-format": { type: "string" },
|
||||||
|
skills: { type: "string" },
|
||||||
link: { type: "boolean" },
|
link: { type: "boolean" },
|
||||||
unlink: { type: "boolean" },
|
unlink: { type: "boolean" },
|
||||||
},
|
},
|
||||||
@@ -134,6 +136,7 @@ async function main() {
|
|||||||
const forceNew = (values.new as boolean | undefined) ?? false;
|
const forceNew = (values.new as boolean | undefined) ?? false;
|
||||||
const specifiedAgentId = (values.agent as string | undefined) ?? null;
|
const specifiedAgentId = (values.agent as string | undefined) ?? null;
|
||||||
const specifiedModel = (values.model as string | undefined) ?? undefined;
|
const specifiedModel = (values.model as string | undefined) ?? undefined;
|
||||||
|
const skillsDirectory = (values.skills as string | undefined) ?? undefined;
|
||||||
const isHeadless = values.prompt || values.run || !process.stdin.isTTY;
|
const isHeadless = values.prompt || values.run || !process.stdin.isTTY;
|
||||||
|
|
||||||
// Check if API key is configured
|
// Check if API key is configured
|
||||||
@@ -257,7 +260,7 @@ async function main() {
|
|||||||
await upsertToolsToServer(client);
|
await upsertToolsToServer(client);
|
||||||
|
|
||||||
const { handleHeadlessCommand } = await import("./headless");
|
const { handleHeadlessCommand } = await import("./headless");
|
||||||
await handleHeadlessCommand(process.argv, specifiedModel);
|
await handleHeadlessCommand(process.argv, specifiedModel, skillsDirectory);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -273,11 +276,13 @@ async function main() {
|
|||||||
forceNew,
|
forceNew,
|
||||||
agentIdArg,
|
agentIdArg,
|
||||||
model,
|
model,
|
||||||
|
skillsDirectory,
|
||||||
}: {
|
}: {
|
||||||
continueSession: boolean;
|
continueSession: boolean;
|
||||||
forceNew: boolean;
|
forceNew: boolean;
|
||||||
agentIdArg: string | null;
|
agentIdArg: string | null;
|
||||||
model?: string;
|
model?: string;
|
||||||
|
skillsDirectory?: string;
|
||||||
}) {
|
}) {
|
||||||
const [loadingState, setLoadingState] = useState<
|
const [loadingState, setLoadingState] = useState<
|
||||||
| "assembling"
|
| "assembling"
|
||||||
@@ -357,6 +362,7 @@ async function main() {
|
|||||||
undefined,
|
undefined,
|
||||||
updateArgs,
|
updateArgs,
|
||||||
forceNew,
|
forceNew,
|
||||||
|
skillsDirectory,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -394,7 +400,14 @@ async function main() {
|
|||||||
// Priority 5: Create a new agent
|
// Priority 5: Create a new agent
|
||||||
if (!agent) {
|
if (!agent) {
|
||||||
const updateArgs = getModelUpdateArgs(model);
|
const updateArgs = getModelUpdateArgs(model);
|
||||||
agent = await createAgent(undefined, model, undefined, updateArgs);
|
agent = await createAgent(
|
||||||
|
undefined,
|
||||||
|
model,
|
||||||
|
undefined,
|
||||||
|
updateArgs,
|
||||||
|
false,
|
||||||
|
skillsDirectory,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure local project settings are loaded before updating
|
// Ensure local project settings are loaded before updating
|
||||||
@@ -461,6 +474,7 @@ async function main() {
|
|||||||
forceNew: forceNew,
|
forceNew: forceNew,
|
||||||
agentIdArg: specifiedAgentId,
|
agentIdArg: specifiedAgentId,
|
||||||
model: specifiedModel,
|
model: specifiedModel,
|
||||||
|
skillsDirectory: skillsDirectory,
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
exitOnCtrlC: false, // We handle CTRL-C manually with double-press guard
|
exitOnCtrlC: false, // We handle CTRL-C manually with double-press guard
|
||||||
|
|||||||
@@ -42,15 +42,6 @@
|
|||||||
"context_window": 272000
|
"context_window": 272000
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"id": "glm-4.6",
|
|
||||||
"handle": "openrouter/z-ai/glm-4.6:exacto",
|
|
||||||
"label": "GLM-4.6",
|
|
||||||
"description": "The best open weights coding model",
|
|
||||||
"updateArgs": {
|
|
||||||
"context_window": 200000
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"id": "gpt-5-minimal",
|
"id": "gpt-5-minimal",
|
||||||
"handle": "openai/gpt-5",
|
"handle": "openai/gpt-5",
|
||||||
@@ -95,6 +86,64 @@
|
|||||||
"context_window": 272000
|
"context_window": 272000
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"id": "gpt-5-mini-medium",
|
||||||
|
"handle": "openai/gpt-5-mini-2025-08-07",
|
||||||
|
"label": "GPT-5-Mini (medium)",
|
||||||
|
"description": "OpenAI's latest mini model (using their recommended reasoning level)",
|
||||||
|
"updateArgs": {
|
||||||
|
"reasoning_effort": "medium",
|
||||||
|
"verbosity": "medium",
|
||||||
|
"context_window": 272000
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "gpt-5-nano-medium",
|
||||||
|
"handle": "openai/gpt-5-nano-2025-08-07",
|
||||||
|
"label": "GPT-5-Nano (medium)",
|
||||||
|
"description": "OpenAI's latest nano model (using their recommended reasoning level)",
|
||||||
|
"updateArgs": {
|
||||||
|
"reasoning_effort": "medium",
|
||||||
|
"verbosity": "medium",
|
||||||
|
"context_window": 272000
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "glm-4.6",
|
||||||
|
"handle": "openrouter/z-ai/glm-4.6:exacto",
|
||||||
|
"label": "GLM-4.6",
|
||||||
|
"description": "The best open weights coding model",
|
||||||
|
"updateArgs": {
|
||||||
|
"context_window": 200000
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "minimax-m2",
|
||||||
|
"handle": "openrouter/minimax/minimax-m2",
|
||||||
|
"label": "Minimax M2",
|
||||||
|
"description": "Minimax's latest model",
|
||||||
|
"updateArgs": {
|
||||||
|
"context_window": 196000
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "kimi-k2",
|
||||||
|
"handle": "openrouter/moonshotai/kimi-k2-0905",
|
||||||
|
"label": "Kimi K2",
|
||||||
|
"description": "Kimi's latest model",
|
||||||
|
"updateArgs": {
|
||||||
|
"context_window": 262144
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "deepseek-chat-v3.1",
|
||||||
|
"handle": "openrouter/deepseek/deepseek-chat-v3.1",
|
||||||
|
"label": "DeepSeek Chat V3.1",
|
||||||
|
"description": "DeepSeek V3.1 model",
|
||||||
|
"updateArgs": {
|
||||||
|
"context_window": 128000
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "gemini-flash",
|
"id": "gemini-flash",
|
||||||
"handle": "google_ai/gemini-2.5-flash",
|
"handle": "google_ai/gemini-2.5-flash",
|
||||||
@@ -114,7 +163,21 @@
|
|||||||
"handle": "openai/gpt-4.1",
|
"handle": "openai/gpt-4.1",
|
||||||
"label": "GPT-4.1",
|
"label": "GPT-4.1",
|
||||||
"description": "OpenAI's most recent non-reasoner model",
|
"description": "OpenAI's most recent non-reasoner model",
|
||||||
"updateArgs": { "context_window": 180000 }
|
"updateArgs": { "context_window": 1047576 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "gpt-4.1-mini",
|
||||||
|
"handle": "openai/gpt-4.1-mini-2025-04-14",
|
||||||
|
"label": "GPT-4.1-Mini",
|
||||||
|
"description": "OpenAI's most recent non-reasoner model (mini version)",
|
||||||
|
"updateArgs": { "context_window": 1047576 }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "gpt-4.1-nano",
|
||||||
|
"handle": "openai/gpt-4.1-nano-2025-04-14",
|
||||||
|
"label": "GPT-4.1-Nano",
|
||||||
|
"description": "OpenAI's most recent non-reasoner model (nano version)",
|
||||||
|
"updateArgs": { "context_window": 1047576 }
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "o4-mini",
|
"id": "o4-mini",
|
||||||
|
|||||||
Reference in New Issue
Block a user