diff --git a/src/skills/builtin/initializing-memory/SKILL.md b/src/skills/builtin/initializing-memory/SKILL.md index 4d7e4ee..72ddcfa 100644 --- a/src/skills/builtin/initializing-memory/SKILL.md +++ b/src/skills/builtin/initializing-memory/SKILL.md @@ -18,6 +18,18 @@ This command may be run in different scenarios: Before making changes, use the `memory` tool to inspect your current memory blocks and understand what already exists. +## Memory Migration Option + +If you're setting up a new agent that should inherit memory from an existing agent, consider using the `migrating-memory` skill: + +1. Load the skill: `Skill({ command: "load", skills: ["migrating-memory"] })` +2. Follow its workflow to copy or share blocks from another agent + +**When to suggest migration**: +- User mentions they have an existing agent with useful memory +- User is replacing an old agent with a new one +- User wants to share memory blocks across multiple agents + ## What Coding Agents Should Remember ### 1. Procedures (Rules & Workflows) diff --git a/src/skills/builtin/migrating-memory/SKILL.md b/src/skills/builtin/migrating-memory/SKILL.md new file mode 100644 index 0000000..600d8f7 --- /dev/null +++ b/src/skills/builtin/migrating-memory/SKILL.md @@ -0,0 +1,118 @@ +--- +name: migrating-memory +description: Migrate memory blocks from an existing agent to the current agent. Use when the user wants to copy or share memory from another agent, or during /init when setting up a new agent that should inherit memory from an existing one. +--- + +# Migrating Memory + +This skill helps migrate memory blocks from an existing agent to a new agent, similar to macOS Migration Assistant for AI agents. + +## When to Use This Skill + +- User is setting up a new agent that should inherit memory from an existing one +- User wants to share memory blocks across multiple agents +- User is replacing an old agent with a new one +- User mentions they have an existing agent with useful memory + +## Migration Methods + +### 1. Copy (Independent Blocks) + +Creates new blocks with the same content. After copying: +- The new agent owns its copy +- Changes to one agent's block don't affect the other +- Best for: One-time migration, forking an agent + +### 2. Share (Linked Blocks) + +Attaches the same block to multiple agents. After sharing: +- All agents see the same block content +- Changes by any agent are visible to all others +- Can be read-only (target can read but not modify) +- Best for: Shared knowledge bases, synchronized state + +## Workflow + +### Step 1: List Available Agents + +Find the source agent you want to migrate from: + +```bash +npx ts-node scripts/list-agents.ts +``` + +This outputs all agents you have access to with their IDs and names. + +### Step 2: View Source Agent's Blocks + +Inspect what memory blocks the source agent has: + +```bash +npx ts-node scripts/get-agent-blocks.ts --agent-id +``` + +This shows each block's ID, label, description, and value. + +### Step 3: Migrate Blocks + +For each block you want to migrate, choose copy or share: + +**To Copy (create independent block):** +```bash +npx ts-node scripts/copy-block.ts --block-id +``` + +**To Share (attach existing block):** +```bash +npx ts-node scripts/attach-block.ts --block-id +``` + +Add `--read-only` flag to share to make this agent unable to modify the block. + +Note: These scripts automatically target the current agent (you) for safety. + +## Script Reference + +All scripts are located in the `scripts/` directory and output raw API responses (JSON). + +| Script | Purpose | Required Args | +|--------|---------|---------------| +| `list-agents.ts` | List all accessible agents | (none) | +| `get-agent-blocks.ts` | Get blocks from an agent | `--agent-id` | +| `copy-block.ts` | Copy block to current agent | `--block-id` | +| `attach-block.ts` | Attach existing block to current agent | `--block-id`, optional `--read-only` | + +## Authentication + +The bundled scripts automatically use the same authentication as Letta Code: +- Keychain/secrets storage +- `~/.config/letta/settings.json` fallback +- `LETTA_API_KEY` environment variable + +You can also make direct API calls using the Letta SDK if you have the API key available. + +## Example: Migrating Project Memory + +Scenario: You're a new agent and want to inherit memory from an existing agent "ProjectX-v1". + +1. **Find source agent:** + ```bash + npx ts-node scripts/list-agents.ts + # Find "ProjectX-v1" ID: agent-abc123 + ``` + +2. **List its blocks:** + ```bash + npx ts-node scripts/get-agent-blocks.ts --agent-id agent-abc123 + # Shows: project (block-def456), human (block-ghi789), persona (block-jkl012) + ``` + +3. **Copy project knowledge to yourself:** + ```bash + npx ts-node scripts/copy-block.ts --block-id block-def456 + ``` + +4. **Optionally share human preferences (read-only):** + ```bash + npx ts-node scripts/attach-block.ts --block-id block-ghi789 --read-only + ``` diff --git a/src/skills/builtin/migrating-memory/scripts/attach-block.ts b/src/skills/builtin/migrating-memory/scripts/attach-block.ts new file mode 100644 index 0000000..0d8e2d2 --- /dev/null +++ b/src/skills/builtin/migrating-memory/scripts/attach-block.ts @@ -0,0 +1,100 @@ +#!/usr/bin/env npx ts-node +/** + * Attach Block - Attaches an existing memory block to an agent (sharing) + * + * Usage: + * npx ts-node attach-block.ts --block-id --target-agent-id [--read-only] + * + * This attaches an existing block to another agent, making it shared. + * Changes to the block will be visible to all agents that have it attached. + * + * Options: + * --read-only Target agent can read but not modify the block + * + * Output: + * Raw API response from the attach operation + */ + +import type Letta from "@letta-ai/letta-client"; +import { getClient } from "../../../../agent/client"; +import { getCurrentAgentId } from "../../../../agent/context"; +import { settingsManager } from "../../../../settings-manager"; + +/** + * Attach an existing block to the current agent (sharing it) + * @param client - Letta client instance + * @param blockId - The block ID to attach + * @param readOnly - Whether this agent should have read-only access + * @param targetAgentId - Optional target agent ID (defaults to current agent) + * @returns API response from the attach operation + */ +export async function attachBlock( + client: Letta, + blockId: string, + readOnly = false, + targetAgentId?: string, +): Promise>> { + // Get current agent ID (the agent calling this script) or use provided ID + const currentAgentId = targetAgentId ?? getCurrentAgentId(); + + const result = await client.agents.blocks.attach(blockId, { + agent_id: currentAgentId, + }); + + // If read-only is requested, update the block's read_only flag for this agent + // Note: This may require a separate API call depending on how read_only works + if (readOnly) { + // The read_only flag is per-block, not per-agent attachment + // For now, we'll note this in the output + console.warn( + "Note: read_only flag is set on the block itself, not per-agent. " + + "Use the block update API to set read_only if needed.", + ); + } + + return result; +} + +function parseArgs(args: string[]): { + blockId: string; + readOnly: boolean; +} { + const blockIdIndex = args.indexOf("--block-id"); + const readOnly = args.includes("--read-only"); + + if (blockIdIndex === -1 || blockIdIndex + 1 >= args.length) { + throw new Error("Missing required argument: --block-id "); + } + + return { + blockId: args[blockIdIndex + 1] as string, + readOnly, + }; +} + +// CLI entry point +if (require.main === module) { + (async () => { + try { + const { blockId, readOnly } = parseArgs(process.argv.slice(2)); + await settingsManager.initialize(); + const client = await getClient(); + const result = await attachBlock(client, blockId, readOnly); + console.log(JSON.stringify(result, null, 2)); + } catch (error) { + console.error( + "Error:", + error instanceof Error ? error.message : String(error), + ); + if ( + error instanceof Error && + error.message.includes("Missing required argument") + ) { + console.error( + "\nUsage: npx ts-node attach-block.ts --block-id [--read-only]", + ); + } + process.exit(1); + } + })(); +} diff --git a/src/skills/builtin/migrating-memory/scripts/copy-block.ts b/src/skills/builtin/migrating-memory/scripts/copy-block.ts new file mode 100644 index 0000000..18826be --- /dev/null +++ b/src/skills/builtin/migrating-memory/scripts/copy-block.ts @@ -0,0 +1,100 @@ +#!/usr/bin/env npx ts-node +/** + * Copy Block - Copies a memory block to create a new independent block for another agent + * + * Usage: + * npx ts-node copy-block.ts --block-id --target-agent-id + * + * This creates a new block with the same content as the source block, + * then attaches it to the target agent. Changes to the new block + * won't affect the original. + * + * Output: + * Raw API response from each step (retrieve, create, attach) + */ + +import type Letta from "@letta-ai/letta-client"; +import { getClient } from "../../../../agent/client"; +import { getCurrentAgentId } from "../../../../agent/context"; +import { settingsManager } from "../../../../settings-manager"; + +interface CopyBlockResult { + sourceBlock: Awaited>; + newBlock: Awaited>; + attachResult: Awaited< + ReturnType + >; +} + +/** + * Copy a block's content to a new block and attach to the current agent + * @param client - Letta client instance + * @param blockId - The source block ID to copy from + * @param targetAgentId - Optional target agent ID (defaults to current agent) + * @returns Object containing source block, new block, and attach result + */ +export async function copyBlock( + client: Letta, + blockId: string, + targetAgentId?: string, +): Promise { + // Get current agent ID (the agent calling this script) or use provided ID + const currentAgentId = targetAgentId ?? getCurrentAgentId(); + + // 1. Get source block details + const sourceBlock = await client.blocks.retrieve(blockId); + + // 2. Create new block with same content + const newBlock = await client.blocks.create({ + label: sourceBlock.label || "migrated-block", + value: sourceBlock.value, + description: sourceBlock.description || undefined, + limit: sourceBlock.limit, + }); + + // 3. Attach new block to current agent + const attachResult = await client.agents.blocks.attach(newBlock.id, { + agent_id: currentAgentId, + }); + + return { sourceBlock, newBlock, attachResult }; +} + +function parseArgs(args: string[]): { blockId: string } { + const blockIdIndex = args.indexOf("--block-id"); + + if (blockIdIndex === -1 || blockIdIndex + 1 >= args.length) { + throw new Error("Missing required argument: --block-id "); + } + + return { + blockId: args[blockIdIndex + 1] as string, + }; +} + +// CLI entry point +if (require.main === module) { + (async () => { + try { + const { blockId } = parseArgs(process.argv.slice(2)); + await settingsManager.initialize(); + const client = await getClient(); + const result = await copyBlock(client, blockId); + console.log(JSON.stringify(result, null, 2)); + } catch (error) { + console.error( + "Error:", + error instanceof Error ? error.message : String(error), + ); + if ( + error instanceof Error && + error.message.includes("Missing required argument") + ) { + console.error( + "\nUsage: npx ts-node copy-block.ts --block-id ", + ); + } + process.exit(1); + } + })(); +} diff --git a/src/skills/builtin/migrating-memory/scripts/get-agent-blocks.ts b/src/skills/builtin/migrating-memory/scripts/get-agent-blocks.ts new file mode 100644 index 0000000..c9e72b3 --- /dev/null +++ b/src/skills/builtin/migrating-memory/scripts/get-agent-blocks.ts @@ -0,0 +1,62 @@ +#!/usr/bin/env npx ts-node +/** + * Get Agent Blocks - Retrieves memory blocks from a specific agent + * + * Usage: + * npx ts-node get-agent-blocks.ts --agent-id + * + * Output: + * Raw API response from GET /v1/agents/{id}/core-memory/blocks + */ + +import type Letta from "@letta-ai/letta-client"; +import { getClient } from "../../../../agent/client"; +import { settingsManager } from "../../../../settings-manager"; + +/** + * Get memory blocks for a specific agent + * @param client - Letta client instance + * @param agentId - The agent ID to get blocks from + * @returns Array of block objects from the API + */ +export async function getAgentBlocks( + client: Letta, + agentId: string, +): Promise>> { + return await client.agents.blocks.list(agentId); +} + +function parseArgs(args: string[]): { agentId: string } { + const agentIdIndex = args.indexOf("--agent-id"); + if (agentIdIndex === -1 || agentIdIndex + 1 >= args.length) { + throw new Error("Missing required argument: --agent-id "); + } + return { agentId: args[agentIdIndex + 1] as string }; +} + +// CLI entry point +if (require.main === module) { + (async () => { + try { + const { agentId } = parseArgs(process.argv.slice(2)); + await settingsManager.initialize(); + const client = await getClient(); + const result = await getAgentBlocks(client, agentId); + console.log(JSON.stringify(result, null, 2)); + } catch (error) { + console.error( + "Error:", + error instanceof Error ? error.message : String(error), + ); + if ( + error instanceof Error && + error.message.includes("Missing required argument") + ) { + console.error( + "\nUsage: npx ts-node get-agent-blocks.ts --agent-id ", + ); + } + process.exit(1); + } + })(); +} diff --git a/src/skills/builtin/migrating-memory/scripts/list-agents.ts b/src/skills/builtin/migrating-memory/scripts/list-agents.ts new file mode 100644 index 0000000..7674919 --- /dev/null +++ b/src/skills/builtin/migrating-memory/scripts/list-agents.ts @@ -0,0 +1,43 @@ +#!/usr/bin/env npx ts-node +/** + * List Agents - Lists all agents accessible to the user + * + * Usage: + * npx ts-node list-agents.ts + * + * Output: + * Raw API response from GET /v1/agents + */ + +import type Letta from "@letta-ai/letta-client"; +import { getClient } from "../../../../agent/client"; +import { settingsManager } from "../../../../settings-manager"; + +/** + * List all agents accessible to the user + * @param client - Letta client instance + * @returns Array of agent objects from the API + */ +export async function listAgents( + client: Letta, +): Promise>> { + return await client.agents.list(); +} + +// CLI entry point +if (require.main === module) { + (async () => { + try { + await settingsManager.initialize(); + const client = await getClient(); + const result = await listAgents(client); + console.log(JSON.stringify(result, null, 2)); + } catch (error) { + console.error( + "Error:", + error instanceof Error ? error.message : String(error), + ); + process.exit(1); + } + })(); +} diff --git a/src/tests/skills/memory-migration-scripts.test.ts b/src/tests/skills/memory-migration-scripts.test.ts new file mode 100644 index 0000000..2b41546 --- /dev/null +++ b/src/tests/skills/memory-migration-scripts.test.ts @@ -0,0 +1,273 @@ +/** + * Tests for the bundled memory-migration scripts + */ + +import { describe, expect, mock, test } from "bun:test"; +import type Letta from "@letta-ai/letta-client"; +import { attachBlock } from "../../skills/builtin/migrating-memory/scripts/attach-block"; +import { copyBlock } from "../../skills/builtin/migrating-memory/scripts/copy-block"; +import { getAgentBlocks } from "../../skills/builtin/migrating-memory/scripts/get-agent-blocks"; +import { listAgents } from "../../skills/builtin/migrating-memory/scripts/list-agents"; + +// Mock data +const mockAgentsResponse = [ + { id: "agent-123", name: "Test Agent 1" }, + { id: "agent-456", name: "Test Agent 2" }, +]; + +const mockBlocksResponse = [ + { + id: "block-abc", + label: "project", + description: "Project info", + value: "Test project content", + }, + { + id: "block-def", + label: "human", + description: "Human preferences", + value: "Test human content", + }, +]; + +const mockBlock = { + id: "block-abc", + label: "project", + description: "Project info", + value: "Test project content", + limit: 5000, +}; + +const mockNewBlock = { + id: "block-new-123", + label: "project", + description: "Project info", + value: "Test project content", + limit: 5000, +}; + +const mockAgentState = { + id: "agent-789", + name: "Target Agent", + agent_type: "memgpt_agent", + blocks: [], + llm_config: {}, + memory: {}, + embedding_config: {}, + project_id: "project-123", + created_at: "2024-01-01", + updated_at: "2024-01-01", +}; + +describe("list-agents", () => { + test("calls client.agents.list and returns result", async () => { + const mockList = mock(() => Promise.resolve(mockAgentsResponse)); + const mockClient = { + agents: { + list: mockList, + }, + } as unknown as Letta; + + const result = await listAgents(mockClient); + expect(mockList).toHaveBeenCalled(); + expect(result).toBeDefined(); + }); + + test("handles empty agent list", async () => { + const mockClient = { + agents: { + list: mock(() => Promise.resolve([])), + }, + } as unknown as Letta; + + const result = await listAgents(mockClient); + expect(result).toBeDefined(); + }); + + test("propagates API errors", async () => { + const mockClient = { + agents: { + list: mock(() => Promise.reject(new Error("API Error"))), + }, + } as unknown as Letta; + + await expect(listAgents(mockClient)).rejects.toThrow("API Error"); + }); +}); + +describe("get-agent-blocks", () => { + test("calls client.agents.blocks.list with agent ID", async () => { + const mockList = mock(() => Promise.resolve(mockBlocksResponse)); + const mockClient = { + agents: { + blocks: { + list: mockList, + }, + }, + } as unknown as Letta; + + const result = await getAgentBlocks(mockClient, "agent-123"); + expect(mockList).toHaveBeenCalledWith("agent-123"); + expect(result).toBeDefined(); + }); + + test("handles agent with no blocks", async () => { + const mockClient = { + agents: { + blocks: { + list: mock(() => Promise.resolve([])), + }, + }, + } as unknown as Letta; + + const result = await getAgentBlocks(mockClient, "agent-empty"); + expect(result).toBeDefined(); + }); + + test("propagates API errors", async () => { + const mockClient = { + agents: { + blocks: { + list: mock(() => Promise.reject(new Error("Agent not found"))), + }, + }, + } as unknown as Letta; + + await expect(getAgentBlocks(mockClient, "nonexistent")).rejects.toThrow( + "Agent not found", + ); + }); +}); + +describe("copy-block", () => { + test("retrieves source block, creates new block, and attaches to target agent", async () => { + const mockRetrieve = mock(() => Promise.resolve(mockBlock)); + const mockCreate = mock(() => Promise.resolve(mockNewBlock)); + const mockAttach = mock(() => Promise.resolve(mockAgentState)); + + const mockClient = { + blocks: { + retrieve: mockRetrieve, + create: mockCreate, + }, + agents: { + blocks: { + attach: mockAttach, + }, + }, + } as unknown as Letta; + + // Pass explicit agent ID for testing (in production, defaults to current agent) + const result = await copyBlock(mockClient, "block-abc", "agent-789"); + + expect(mockRetrieve).toHaveBeenCalledWith("block-abc"); + expect(mockCreate).toHaveBeenCalledWith({ + label: "project", + value: "Test project content", + description: "Project info", + limit: 5000, + }); + expect(mockAttach).toHaveBeenCalledWith("block-new-123", { + agent_id: "agent-789", + }); + + expect(result.sourceBlock).toEqual(mockBlock); + expect(result.newBlock).toEqual(mockNewBlock); + expect(result.attachResult).toBeDefined(); + }); + + test("handles block without description", async () => { + const blockWithoutDesc = { ...mockBlock, description: null }; + const mockClient = { + blocks: { + retrieve: mock(() => Promise.resolve(blockWithoutDesc)), + create: mock(() => Promise.resolve(mockNewBlock)), + }, + agents: { + blocks: { + attach: mock(() => Promise.resolve(mockAgentState)), + }, + }, + } as unknown as Letta; + + const result = await copyBlock(mockClient, "block-abc", "agent-789"); + expect(result.newBlock).toBeDefined(); + }); + + test("propagates errors from retrieve", async () => { + const mockClient = { + blocks: { + retrieve: mock(() => Promise.reject(new Error("Block not found"))), + create: mock(() => Promise.resolve(mockNewBlock)), + }, + agents: { + blocks: { + attach: mock(() => Promise.resolve(mockAgentState)), + }, + }, + } as unknown as Letta; + + await expect( + copyBlock(mockClient, "nonexistent", "agent-789"), + ).rejects.toThrow("Block not found"); + }); +}); + +describe("attach-block", () => { + test("attaches existing block to target agent", async () => { + const mockAttach = mock(() => Promise.resolve(mockAgentState)); + const mockClient = { + agents: { + blocks: { + attach: mockAttach, + }, + }, + } as unknown as Letta; + + // Pass explicit agent ID for testing (in production, defaults to current agent) + const result = await attachBlock( + mockClient, + "block-abc", + false, + "agent-789", + ); + + expect(mockAttach).toHaveBeenCalledWith("block-abc", { + agent_id: "agent-789", + }); + expect(result).toBeDefined(); + }); + + test("handles read-only flag without error", async () => { + const mockClient = { + agents: { + blocks: { + attach: mock(() => Promise.resolve(mockAgentState)), + }, + }, + } as unknown as Letta; + + // The function should work with read-only flag (currently just warns) + const result = await attachBlock( + mockClient, + "block-abc", + true, + "agent-789", + ); + expect(result).toBeDefined(); + }); + + test("propagates API errors", async () => { + const mockClient = { + agents: { + blocks: { + attach: mock(() => Promise.reject(new Error("Cannot attach block"))), + }, + }, + } as unknown as Letta; + + await expect( + attachBlock(mockClient, "block-abc", false, "agent-789"), + ).rejects.toThrow("Cannot attach block"); + }); +}); diff --git a/src/tools/impl/Skill.ts b/src/tools/impl/Skill.ts index 5c75b54..798a197 100644 --- a/src/tools/impl/Skill.ts +++ b/src/tools/impl/Skill.ts @@ -1,5 +1,6 @@ +import { readdirSync } from "node:fs"; import { readFile } from "node:fs/promises"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; import { getClient } from "../../agent/client"; import { getCurrentAgentId, @@ -101,19 +102,34 @@ function extractSkillsDir(skillsBlockValue: string): string | null { return match ? match[1]?.trim() || null : null; } +/** + * Check if a skill directory has additional files beyond SKILL.md + */ +function hasAdditionalFiles(skillMdPath: string): boolean { + try { + const skillDir = dirname(skillMdPath); + const entries = readdirSync(skillDir); + return entries.some((e) => e.toUpperCase() !== "SKILL.MD"); + } catch { + return false; + } +} + /** * Read skill content from file or bundled source + * Returns both content and the path to the SKILL.md file */ async function readSkillContent( skillId: string, skillsDir: string, -): Promise { +): Promise<{ content: string; path: string }> { // 1. Check bundled skills first (they have a path now) const bundledSkills = await getBundledSkills(); const bundledSkill = bundledSkills.find((s) => s.id === skillId); if (bundledSkill?.path) { try { - return await readFile(bundledSkill.path, "utf-8"); + const content = await readFile(bundledSkill.path, "utf-8"); + return { content, path: bundledSkill.path }; } catch { // Bundled skill path not found, continue to other sources } @@ -122,21 +138,24 @@ async function readSkillContent( // 2. Try global skills directory const globalSkillPath = join(GLOBAL_SKILLS_DIR, skillId, "SKILL.md"); try { - return await readFile(globalSkillPath, "utf-8"); + const content = await readFile(globalSkillPath, "utf-8"); + return { content, path: globalSkillPath }; } catch { // Not in global, continue } // 3. Try project skills directory - const skillPath = join(skillsDir, skillId, "SKILL.md"); + const projectSkillPath = join(skillsDir, skillId, "SKILL.md"); try { - return await readFile(skillPath, "utf-8"); + const content = await readFile(projectSkillPath, "utf-8"); + return { content, path: projectSkillPath }; } catch (primaryError) { // Fallback: check for bundled skills in a repo-level skills directory (legacy) try { const bundledSkillsDir = join(process.cwd(), "skills", "skills"); const bundledSkillPath = join(bundledSkillsDir, skillId, "SKILL.md"); - return await readFile(bundledSkillPath, "utf-8"); + const content = await readFile(bundledSkillPath, "utf-8"); + return { content, path: bundledSkillPath }; } catch { // If all fallbacks fail, rethrow the original error throw primaryError; @@ -259,16 +278,23 @@ export async function skill(args: SkillArgs): Promise { } try { - const skillContent = await readSkillContent(skillId, skillsDir); + const { content: skillContent, path: skillPath } = + await readSkillContent(skillId, skillsDir); // Replace placeholder if this is the first skill if (currentValue === "[CURRENTLY EMPTY]") { currentValue = ""; } + // Build skill header with optional path info + const skillDir = dirname(skillPath); + const pathLine = hasAdditionalFiles(skillPath) + ? `# Skill Directory: ${skillDir}\n\n` + : ""; + // Append new skill const separator = currentValue ? "\n\n---\n\n" : ""; - currentValue = `${currentValue}${separator}# Skill: ${skillId}\n${skillContent}`; + currentValue = `${currentValue}${separator}# Skill: ${skillId}\n${pathLine}${skillContent}`; loadedSkillIds.push(skillId); results.push(`"${skillId}" loaded`); } catch (error) {