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
|
||||
|
||||
# User-defined skills
|
||||
.skills
|
||||
|
||||
.idea
|
||||
node_modules
|
||||
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.
|
||||
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
|
||||
|
||||
### Interactive Mode
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
* 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 {
|
||||
BlockResponse,
|
||||
@@ -11,9 +12,14 @@ import { settingsManager } from "../settings-manager";
|
||||
import { getToolNames } from "../tools/manager";
|
||||
import { getClient } from "./client";
|
||||
import { getDefaultMemoryBlocks } from "./memory";
|
||||
import { formatAvailableModels, resolveModel } from "./model";
|
||||
import {
|
||||
formatAvailableModels,
|
||||
getModelUpdateArgs,
|
||||
resolveModel,
|
||||
} from "./model";
|
||||
import { updateAgentLLMConfig } from "./modify";
|
||||
import { SYSTEM_PROMPT } from "./promptAssets";
|
||||
import { discoverSkills, formatSkillsForMemory, SKILLS_DIR } from "./skills";
|
||||
|
||||
export async function createAgent(
|
||||
name = "letta-cli-agent",
|
||||
@@ -21,6 +27,7 @@ export async function createAgent(
|
||||
embeddingModel = "openai/text-embedding-3-small",
|
||||
updateArgs?: Record<string, unknown>,
|
||||
forceNewBlocks = false,
|
||||
skillsDirectory?: string,
|
||||
) {
|
||||
// Resolve model identifier to handle
|
||||
let modelHandle: string;
|
||||
@@ -52,6 +59,36 @@ export async function createAgent(
|
||||
// Load memory blocks from .mdx files
|
||||
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
|
||||
const settings = settingsManager.getSettings();
|
||||
const globalSharedBlockIds = settings.globalSharedBlockIds;
|
||||
@@ -123,8 +160,8 @@ export async function createAgent(
|
||||
}
|
||||
blockIds.push(createdBlock.id);
|
||||
|
||||
// Categorize: style is local, persona/human are global
|
||||
if (label === "project") {
|
||||
// Categorize: project/skills are local, persona/human are global
|
||||
if (label === "project" || label === "skills") {
|
||||
newLocalBlockIds[label] = createdBlock.id;
|
||||
} else {
|
||||
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)
|
||||
const agent = await client.agents.create({
|
||||
agent_type: "letta_v1_agent" as AgentType,
|
||||
@@ -165,7 +206,7 @@ export async function createAgent(
|
||||
name,
|
||||
embedding: embeddingModel,
|
||||
model: modelHandle,
|
||||
context_window_limit: 200_000,
|
||||
context_window_limit: contextWindow,
|
||||
tools: toolNames,
|
||||
block_ids: blockIds,
|
||||
tags: ["origin:letta-code"],
|
||||
@@ -182,9 +223,8 @@ export async function createAgent(
|
||||
// Apply updateArgs if provided (e.g., reasoningEffort, contextWindow, etc.)
|
||||
if (updateArgs && Object.keys(updateArgs).length > 0) {
|
||||
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[]> {
|
||||
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_kawaii.mdx", "human.mdx", "style.mdx"];
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import personaPrompt from "./prompts/persona.mdx";
|
||||
import personaKawaiiPrompt from "./prompts/persona_kawaii.mdx";
|
||||
import planModeReminder from "./prompts/plan_mode_reminder.txt";
|
||||
import projectPrompt from "./prompts/project.mdx";
|
||||
import skillsPrompt from "./prompts/skills.mdx";
|
||||
import stylePrompt from "./prompts/style.mdx";
|
||||
import systemPrompt from "./prompts/system_prompt.txt";
|
||||
|
||||
@@ -14,6 +15,7 @@ export const MEMORY_PROMPTS: Record<string, string> = {
|
||||
"persona.mdx": personaPrompt,
|
||||
"human.mdx": humanPrompt,
|
||||
"project.mdx": projectPrompt,
|
||||
"skills.mdx": skillsPrompt,
|
||||
"style.mdx": stylePrompt,
|
||||
"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]
|
||||
@@ -22,4 +22,18 @@ 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 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 { Box, Text, useInput } from "ink";
|
||||
import Link from "ink-link";
|
||||
import SpinnerLib from "ink-spinner";
|
||||
import type { ComponentType } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
@@ -17,19 +17,37 @@ import { drainStreamWithResume } from "./cli/helpers/stream";
|
||||
import { settingsManager } from "./settings-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();
|
||||
|
||||
// Parse CLI args
|
||||
// Include all flags from index.ts to prevent them from being treated as positionals
|
||||
const { values, positionals } = parseArgs({
|
||||
args: argv,
|
||||
options: {
|
||||
// Flags used in headless mode
|
||||
continue: { type: "boolean", short: "c" },
|
||||
new: { type: "boolean" },
|
||||
agent: { type: "string", short: "a" },
|
||||
model: { type: "string", short: "m" },
|
||||
prompt: { type: "boolean", short: "p" },
|
||||
"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,
|
||||
allowPositionals: true,
|
||||
@@ -81,6 +99,7 @@ export async function handleHeadlessCommand(argv: string[], model?: string) {
|
||||
undefined,
|
||||
updateArgs,
|
||||
forceNew,
|
||||
skillsDirectory,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -113,7 +132,14 @@ export async function handleHeadlessCommand(argv: string[], model?: string) {
|
||||
// Priority 5: Create a new agent
|
||||
if (!agent) {
|
||||
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
|
||||
|
||||
18
src/index.ts
18
src/index.ts
@@ -32,6 +32,7 @@ OPTIONS
|
||||
-p, --prompt Headless prompt mode
|
||||
--output-format <fmt> Output format for headless mode (text, json, stream-json)
|
||||
Default: text
|
||||
--skills <path> Custom path to skills directory (default: .skills in current directory)
|
||||
|
||||
BEHAVIOR
|
||||
By default, letta auto-resumes the last agent used in the current directory
|
||||
@@ -91,6 +92,7 @@ async function main() {
|
||||
"permission-mode": { type: "string" },
|
||||
yolo: { type: "boolean" },
|
||||
"output-format": { type: "string" },
|
||||
skills: { type: "string" },
|
||||
link: { type: "boolean" },
|
||||
unlink: { type: "boolean" },
|
||||
},
|
||||
@@ -134,6 +136,7 @@ async function main() {
|
||||
const forceNew = (values.new as boolean | undefined) ?? false;
|
||||
const specifiedAgentId = (values.agent as string | undefined) ?? null;
|
||||
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;
|
||||
|
||||
// Check if API key is configured
|
||||
@@ -257,7 +260,7 @@ async function main() {
|
||||
await upsertToolsToServer(client);
|
||||
|
||||
const { handleHeadlessCommand } = await import("./headless");
|
||||
await handleHeadlessCommand(process.argv, specifiedModel);
|
||||
await handleHeadlessCommand(process.argv, specifiedModel, skillsDirectory);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -273,11 +276,13 @@ async function main() {
|
||||
forceNew,
|
||||
agentIdArg,
|
||||
model,
|
||||
skillsDirectory,
|
||||
}: {
|
||||
continueSession: boolean;
|
||||
forceNew: boolean;
|
||||
agentIdArg: string | null;
|
||||
model?: string;
|
||||
skillsDirectory?: string;
|
||||
}) {
|
||||
const [loadingState, setLoadingState] = useState<
|
||||
| "assembling"
|
||||
@@ -357,6 +362,7 @@ async function main() {
|
||||
undefined,
|
||||
updateArgs,
|
||||
forceNew,
|
||||
skillsDirectory,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -394,7 +400,14 @@ async function main() {
|
||||
// Priority 5: Create a new agent
|
||||
if (!agent) {
|
||||
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
|
||||
@@ -461,6 +474,7 @@ async function main() {
|
||||
forceNew: forceNew,
|
||||
agentIdArg: specifiedAgentId,
|
||||
model: specifiedModel,
|
||||
skillsDirectory: skillsDirectory,
|
||||
}),
|
||||
{
|
||||
exitOnCtrlC: false, // We handle CTRL-C manually with double-press guard
|
||||
|
||||
@@ -42,15 +42,6 @@
|
||||
"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",
|
||||
"handle": "openai/gpt-5",
|
||||
@@ -95,6 +86,64 @@
|
||||
"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",
|
||||
"handle": "google_ai/gemini-2.5-flash",
|
||||
@@ -114,7 +163,21 @@
|
||||
"handle": "openai/gpt-4.1",
|
||||
"label": "GPT-4.1",
|
||||
"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",
|
||||
|
||||
Reference in New Issue
Block a user