feat: add migrating-memory skill for copying/sharing blocks between agents

- Add migrating-memory bundled skill with 4 scripts:
  - list-agents.ts: List all accessible agents
  - get-agent-blocks.ts: Get memory blocks from an agent
  - copy-block.ts: Copy a block to create independent copy
  - attach-block.ts: Attach existing block (shared)
- Scripts auto-target current agent for safety (no accidental modifications)
- Skill.ts now includes "# Skill Directory:" when loading skills with additional files
- Update initializing-memory to suggest migration option
- Add unit tests for all migration scripts

🐙 Generated with [Letta Code](https://letta.com)

Co-Authored-By: Letta <noreply@letta.com>
This commit is contained in:
cpacker
2026-01-04 11:28:22 -08:00
parent 3ab15cc3e3
commit d0d45cba5a
8 changed files with 743 additions and 9 deletions

View File

@@ -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)

View File

@@ -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 <source-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 <block-id>
```
**To Share (attach existing block):**
```bash
npx ts-node scripts/attach-block.ts --block-id <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
```

View File

@@ -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 <block-id> --target-agent-id <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<Awaited<ReturnType<typeof client.agents.blocks.attach>>> {
// 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 <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 <block-id> [--read-only]",
);
}
process.exit(1);
}
})();
}

View File

@@ -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 <block-id> --target-agent-id <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<ReturnType<typeof Letta.prototype.blocks.retrieve>>;
newBlock: Awaited<ReturnType<typeof Letta.prototype.blocks.create>>;
attachResult: Awaited<
ReturnType<typeof Letta.prototype.agents.blocks.attach>
>;
}
/**
* 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<CopyBlockResult> {
// 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 <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 <block-id>",
);
}
process.exit(1);
}
})();
}

View File

@@ -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 <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<Awaited<ReturnType<typeof client.agents.blocks.list>>> {
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 <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 <agent-id>",
);
}
process.exit(1);
}
})();
}

View File

@@ -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<Awaited<ReturnType<typeof client.agents.list>>> {
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);
}
})();
}

View File

@@ -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");
});
});

View File

@@ -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<string> {
): 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<SkillResult> {
}
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) {