From 11136312520e459c6779dfc185f9822bbf988c97 Mon Sep 17 00:00:00 2001 From: Cameron Date: Wed, 4 Feb 2026 17:25:53 -0800 Subject: [PATCH] feat(skills): install to agent-scoped location instead of .skills/ (#148) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Skills now install to ~/.letta/agents/{agentId}/skills/ after agent creation, aligning with Letta Code CLI behavior. This removes the duplicate installation that was happening at both startup and after agent creation. Changes: - Add SkillsConfig type and pass through BotConfig - Update installSkillsToAgent() to actually install skills - Remove installSkillsToWorkingDir() call from main.ts startup - Closes #108 (reimplemented from PR #114 due to conflicts) "The best way to predict the future is to invent it." - Alan Kay Written by Cameron ◯ Letta Code --- src/core/bot.ts | 2 +- src/core/types.ts | 12 ++++++++++++ src/main.ts | 25 +++++++----------------- src/skills/loader.ts | 45 +++++++++++++++++++++++++++++++++++++++++--- 4 files changed, 62 insertions(+), 22 deletions(-) diff --git a/src/core/bot.ts b/src/core/bot.ts index 81a2762..c6db545 100644 --- a/src/core/bot.ts +++ b/src/core/bot.ts @@ -424,7 +424,7 @@ export class LettaBot { updateAgentName(session.agentId, this.config.agentName).catch(() => {}); } if (session.agentId) { - installSkillsToAgent(session.agentId); + installSkillsToAgent(session.agentId, this.config.skills); } } } else if (session.conversationId && session.conversationId !== this.store.conversationId) { diff --git a/src/core/types.ts b/src/core/types.ts index 962218b..b47344d 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -103,6 +103,15 @@ export interface OutboundFile { kind?: 'image' | 'file'; } +/** + * Skills installation config + */ +export interface SkillsConfig { + cronEnabled?: boolean; + googleEnabled?: boolean; + additionalSkills?: string[]; +} + /** * Bot configuration */ @@ -113,6 +122,9 @@ export interface BotConfig { agentName?: string; // Name for the agent (set via API after creation) allowedTools: string[]; + // Skills + skills?: SkillsConfig; + // Security allowedUsers?: string[]; // Empty = allow all } diff --git a/src/main.ts b/src/main.ts index 63bc404..987a9ca 100644 --- a/src/main.ts +++ b/src/main.ts @@ -5,7 +5,7 @@ * Chat continues seamlessly between Telegram, Slack, and WhatsApp. */ -import { existsSync, mkdirSync, readFileSync, readdirSync, promises as fs } from 'node:fs'; +import { existsSync, mkdirSync, readFileSync, promises as fs } from 'node:fs'; import { join, resolve } from 'node:path'; import { spawn } from 'node:child_process'; @@ -123,7 +123,7 @@ import { CronService } from './cron/service.js'; import { HeartbeatService } from './cron/heartbeat.js'; import { PollingService } from './polling/service.js'; import { agentExists, findAgentByName } from './tools/letta-api.js'; -import { installSkillsToWorkingDir } from './skills/loader.js'; +// Skills are now installed to agent-scoped location after agent creation (see bot.ts) // Check if config exists (skip in Railway/Docker where env vars are used directly) const configPath = resolveConfigPath(); @@ -313,27 +313,16 @@ async function main() { console.log(`[Storage] Data directory: ${dataDir}`); console.log(`[Storage] Working directory: ${config.workingDir}`); - // Install feature-gated skills based on enabled features - // Skills are NOT installed by default - only when their feature is enabled - const skillsDir = resolve(config.workingDir, '.skills'); - mkdirSync(skillsDir, { recursive: true }); - - installSkillsToWorkingDir(config.workingDir, { - cronEnabled: config.cronEnabled, - googleEnabled: config.polling.gmail.enabled, // Gmail polling uses gog skill - }); - - const existingSkills = readdirSync(skillsDir).filter(f => !f.startsWith('.')); - if (existingSkills.length > 0) { - console.log(`[Skills] ${existingSkills.length} skill(s) available: ${existingSkills.join(', ')}`); - } - - // Create bot + // Create bot with skills config (skills installed to agent-scoped location after agent creation) const bot = new LettaBot({ workingDir: config.workingDir, model: config.model, agentName: process.env.AGENT_NAME || 'LettaBot', allowedTools: config.allowedTools, + skills: { + cronEnabled: config.cronEnabled, + googleEnabled: config.polling.gmail.enabled, + }, }); const attachmentsDir = resolve(config.workingDir, 'attachments'); diff --git a/src/skills/loader.ts b/src/skills/loader.ts index 392d909..cca76a4 100644 --- a/src/skills/loader.ts +++ b/src/skills/loader.ts @@ -294,8 +294,47 @@ export function installSkillsToWorkingDir(workingDir: string, config: SkillsInst /** - * @deprecated Use installSkillsToWorkingDir instead + * Install feature-gated skills to the agent-scoped skills directory + * (~/.letta/agents/{agentId}/skills/) + * + * This aligns with Letta Code CLI which uses agent-scoped skills. + * Called after agent creation in bot.ts. */ -export function installSkillsToAgent(agentId: string): void { - // No-op - skills are now installed to working dir on startup +export function installSkillsToAgent(agentId: string, config: SkillsInstallConfig = {}): void { + const targetDir = getAgentSkillsDir(agentId); + + // Ensure target directory exists + mkdirSync(targetDir, { recursive: true }); + + // Collect skills to install based on enabled features + const skillsToInstall: string[] = []; + + // Cron skills (always if cron is enabled) + if (config.cronEnabled) { + skillsToInstall.push(...FEATURE_SKILLS.cron); + } + + // Google skills (if Gmail polling or Google is configured) + if (config.googleEnabled) { + skillsToInstall.push(...FEATURE_SKILLS.google); + } + + // Additional explicitly enabled skills + if (config.additionalSkills?.length) { + skillsToInstall.push(...config.additionalSkills); + } + + if (skillsToInstall.length === 0) { + return; // No skills to install - silent return + } + + // Source directories (later has priority) + const sourceDirs = [SKILLS_SH_DIR, BUNDLED_SKILLS_DIR, PROJECT_SKILLS_DIR]; + + // Install the specific skills + const installed = installSpecificSkills(skillsToInstall, sourceDirs, targetDir); + + if (installed.length > 0) { + console.log(`[Skills] Installed ${installed.length} skill(s) to agent: ${installed.join(', ')}`); + } }