diff --git a/bun.lock b/bun.lock index 385a165..bf9d07f 100644 --- a/bun.lock +++ b/bun.lock @@ -4,7 +4,7 @@ "": { "name": "@letta-ai/letta-code", "dependencies": { - "@letta-ai/letta-client": "1.6.3", + "@letta-ai/letta-client": "1.6.4", "glob": "^13.0.0", "ink-link": "^5.0.0", "open": "^10.2.0", @@ -36,7 +36,7 @@ "@isaacs/brace-expansion": ["@isaacs/brace-expansion@5.0.0", "", { "dependencies": { "@isaacs/balanced-match": "^4.0.1" } }, "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA=="], - "@letta-ai/letta-client": ["@letta-ai/letta-client@1.6.3", "", {}, "sha512-WlWONBU2t8z9MynyQqatT9rKQdaP7s+cWSb+3e+2gF79TZ/qZHf9k0QOUgDoZPoTRme+BifVqTAfVxlirPBd8w=="], + "@letta-ai/letta-client": ["@letta-ai/letta-client@1.6.4", "", {}, "sha512-k6aN0/P0XWT4wei8iQZ2mE/n0t68qJprXKnB9p5M64YikRFJpy6TNzbgTcsxCGfc7yOeIgatoKb5BbHMKiLGcg=="], "@types/bun": ["@types/bun@1.3.1", "", { "dependencies": { "bun-types": "1.3.1" } }, "sha512-4jNMk2/K9YJtfqwoAa28c8wK+T7nvJFOjxI4h/7sORWcypRNxBpr+TPNaCfVWq70tLCJsqoFwcf0oI0JU/fvMQ=="], diff --git a/package.json b/package.json index 628e773..b916b2a 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "access": "public" }, "dependencies": { - "@letta-ai/letta-client": "1.6.3", + "@letta-ai/letta-client": "1.6.4", "glob": "^13.0.0", "ink-link": "^5.0.0", "open": "^10.2.0" diff --git a/src/settings-manager.ts b/src/settings-manager.ts index c32284a..40f5d22 100644 --- a/src/settings-manager.ts +++ b/src/settings-manager.ts @@ -459,10 +459,8 @@ class SettingsManager { if (!this.settings) return; const settingsPath = this.getSettingsPath(); - const dirPath = join( - process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), - "letta", - ); + const home = process.env.HOME || homedir(); + const dirPath = join(home, ".letta"); try { if (!exists(dirPath)) { @@ -514,11 +512,9 @@ class SettingsManager { } private getSettingsPath(): string { - return join( - process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), - "letta", - "settings.json", - ); + // Use ~/.letta/ like other AI tools (.claude, .cursor, etc.) + const home = process.env.HOME || homedir(); + return join(home, ".letta", "settings.json"); } private getProjectSettingsPath(workingDirectory: string): string { diff --git a/src/skills/builtin/finding-agents/SKILL.md b/src/skills/builtin/finding-agents/SKILL.md new file mode 100644 index 0000000..6c2aa8f --- /dev/null +++ b/src/skills/builtin/finding-agents/SKILL.md @@ -0,0 +1,115 @@ +--- +name: finding-agents +description: Find other agents on the same server. Use when the user asks about other agents, wants to migrate memory from another agent, or needs to find an agent by name or tags. +--- + +# Finding Agents + +This skill helps you find other agents on the same Letta server. + +## When to Use This Skill + +- User asks about other agents they have +- User wants to find a specific agent by name +- User wants to list agents with certain tags +- You need to find an agent ID for memory migration +- You found an agent_id via message search and need details about that agent + +## Script Usage + +```bash +npx ts-node scripts/find-agents.ts [options] +``` + +### Options + +| Option | Description | +|--------|-------------| +| `--name ` | Exact name match | +| `--query ` | Fuzzy search by name | +| `--tags ` | Filter by tags (comma-separated) | +| `--match-all-tags` | Require ALL tags (default: ANY) | +| `--include-blocks` | Include agent.blocks in response | +| `--limit ` | Max results (default: 20) | + +## Common Patterns + +### Finding Letta Code Agents + +Agents created by Letta Code are tagged with `origin:letta-code`. To find only Letta Code agents: + +```bash +npx ts-node scripts/find-agents.ts --tags "origin:letta-code" +``` + +This is useful when the user is looking for agents they've worked with in Letta Code CLI sessions. + +### Finding All Agents + +If the user has agents created outside Letta Code (via ADE, SDK, etc.), search without the tag filter: + +```bash +npx ts-node scripts/find-agents.ts +``` + +## Examples + +**List all agents (up to 20):** +```bash +npx ts-node scripts/find-agents.ts +``` + +**Find agent by exact name:** +```bash +npx ts-node scripts/find-agents.ts --name "ProjectX-v1" +``` + +**Search agents by name (fuzzy):** +```bash +npx ts-node scripts/find-agents.ts --query "project" +``` + +**Find only Letta Code agents:** +```bash +npx ts-node scripts/find-agents.ts --tags "origin:letta-code" +``` + +**Find agents with multiple tags:** +```bash +npx ts-node scripts/find-agents.ts --tags "frontend,production" --match-all-tags +``` + +**Include memory blocks in results:** +```bash +npx ts-node scripts/find-agents.ts --query "project" --include-blocks +``` + +## Output + +Returns the raw API response with full agent details. Key fields: +- `id` - Agent ID (e.g., `agent-abc123`) +- `name` - Agent name +- `description` - Agent description +- `tags` - Agent tags +- `blocks` - Memory blocks (if `--include-blocks` used) + +## Related Skills + +- **migrating-memory** - Once you find an agent, use this skill to copy/share memory blocks +- **searching-messages** - Search messages across all agents to find which agent discussed a topic. Use `--all-agents` to get `agent_id` values, then use this skill to get full agent details. + +### Finding Agents by Topic + +If you need to find which agent worked on a specific topic: + +1. Load both skills: `searching-messages` and `finding-agents` +2. Search messages across all agents: + ```bash + search-messages.ts --query "topic" --all-agents --limit 10 + ``` +3. Note the `agent_id` values from matching messages +4. Get agent details: + ```bash + find-agents.ts --query "partial-name" + ``` + Or use the agent_id directly in the Letta API diff --git a/src/skills/builtin/finding-agents/scripts/find-agents.ts b/src/skills/builtin/finding-agents/scripts/find-agents.ts new file mode 100644 index 0000000..9905bb3 --- /dev/null +++ b/src/skills/builtin/finding-agents/scripts/find-agents.ts @@ -0,0 +1,134 @@ +#!/usr/bin/env npx ts-node +/** + * Find Agents - Search for agents with various filters + * + * Usage: + * npx ts-node find-agents.ts [options] + * + * Options: + * --name Exact name match + * --query Fuzzy search by name + * --tags Filter by tags (comma-separated) + * --match-all-tags Require ALL tags (default: ANY) + * --include-blocks Include agent.blocks in response + * --limit Max results (default: 20) + * + * 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"; + +interface FindAgentsOptions { + name?: string; + query?: string; + tags?: string[]; + matchAllTags?: boolean; + includeBlocks?: boolean; + limit?: number; +} + +/** + * Find agents matching the given criteria + * @param client - Letta client instance + * @param options - Search options + * @returns Array of agent objects from the API + */ +export async function findAgents( + client: Letta, + options: FindAgentsOptions = {}, +): Promise>> { + const params: Parameters[0] = { + limit: options.limit ?? 20, + }; + + if (options.name) { + params.name = options.name; + } + + if (options.query) { + params.query_text = options.query; + } + + if (options.tags && options.tags.length > 0) { + params.tags = options.tags; + if (options.matchAllTags) { + params.match_all_tags = true; + } + } + + if (options.includeBlocks) { + params.include = ["agent.blocks"]; + } + + return await client.agents.list(params); +} + +function parseArgs(args: string[]): FindAgentsOptions { + const options: FindAgentsOptions = {}; + + const nameIndex = args.indexOf("--name"); + if (nameIndex !== -1 && nameIndex + 1 < args.length) { + options.name = args[nameIndex + 1]; + } + + const queryIndex = args.indexOf("--query"); + if (queryIndex !== -1 && queryIndex + 1 < args.length) { + options.query = args[queryIndex + 1]; + } + + const tagsIndex = args.indexOf("--tags"); + if (tagsIndex !== -1 && tagsIndex + 1 < args.length) { + options.tags = args[tagsIndex + 1]?.split(",").map((t) => t.trim()); + } + + if (args.includes("--match-all-tags")) { + options.matchAllTags = true; + } + + if (args.includes("--include-blocks")) { + options.includeBlocks = true; + } + + const limitIndex = args.indexOf("--limit"); + if (limitIndex !== -1 && limitIndex + 1 < args.length) { + const limit = Number.parseInt(args[limitIndex + 1] as string, 10); + if (!Number.isNaN(limit)) { + options.limit = limit; + } + } + + return options; +} + +// CLI entry point +if (require.main === module) { + (async () => { + try { + const options = parseArgs(process.argv.slice(2)); + await settingsManager.initialize(); + const client = await getClient(); + const result = await findAgents(client, options); + console.log(JSON.stringify(result, null, 2)); + } catch (error) { + console.error( + "Error:", + error instanceof Error ? error.message : String(error), + ); + console.error(` +Usage: npx ts-node find-agents.ts [options] + +Options: + --name Exact name match + --query Fuzzy search by name + --tags Filter by tags (comma-separated) + --match-all-tags Require ALL tags (default: ANY) + --include-blocks Include agent.blocks in response + --limit Max results (default: 20) +`); + process.exit(1); + } + })(); +} diff --git a/src/skills/builtin/migrating-memory/SKILL.md b/src/skills/builtin/migrating-memory/SKILL.md index 600d8f7..1ca0d44 100644 --- a/src/skills/builtin/migrating-memory/SKILL.md +++ b/src/skills/builtin/migrating-memory/SKILL.md @@ -16,32 +16,44 @@ This skill helps migrate memory blocks from an existing agent to a new agent, si ## Migration Methods -### 1. Copy (Independent Blocks) +### 1. Manual Copy (Recommended for partial content) -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 +If you only need **part** of a source block, or the source is messy and needs cleanup: +1. Use `get-agent-blocks.ts` to view the source block's content +2. Use the `memory` tool to create a new block with just the content you want +3. No scripts needed - you have full control over what gets copied + +Best for: Extracting sections, cleaning up messy content, selective migration. + +### 2. Script Copy (Full block duplication) + +Creates new blocks with the same content using `copy-block.ts`. After copying: +- You own the copy - changes don't sync +- Use `--label` flag if you already have a block with that label - Best for: One-time migration, forking an agent -### 2. Share (Linked Blocks) +### 3. Share (Linked Blocks) -Attaches the same block to multiple agents. After sharing: +Attaches the same block to multiple agents using `attach-block.ts`. 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 +**Note:** You cannot have two blocks with the same label. When copying, use `--label` to rename if needed. + ## Workflow -### Step 1: List Available Agents +### Step 1: Identify Source Agent -Find the source agent you want to migrate from: +Ask the user for the source agent's ID (e.g., `agent-abc123`). -```bash -npx ts-node scripts/list-agents.ts +If they don't know the ID, load the **finding-agents** skill to search: +``` +Skill({ command: "load", skills: ["finding-agents"] }) ``` -This outputs all agents you have access to with their IDs and names. +Example: "What's the ID of the agent you want to migrate memory from?" ### Step 2: View Source Agent's Blocks @@ -59,9 +71,11 @@ 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 +npx ts-node scripts/copy-block.ts --block-id [--label ] ``` +Use `--label` if you already have a block with that label (e.g., `--label project-imported`). + **To Share (attach existing block):** ```bash npx ts-node scripts/attach-block.ts --block-id @@ -75,11 +89,10 @@ Note: These scripts automatically target the current agent (you) for safety. 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) | +| Script | Purpose | Args | +|--------|---------|------| | `get-agent-blocks.ts` | Get blocks from an agent | `--agent-id` | -| `copy-block.ts` | Copy block to current agent | `--block-id` | +| `copy-block.ts` | Copy block to current agent | `--block-id`, optional `--label` | | `attach-block.ts` | Attach existing block to current agent | `--block-id`, optional `--read-only` | ## Authentication @@ -95,11 +108,8 @@ You can also make direct API calls using the Letta SDK if you have the API key a 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 - ``` +1. **Get source agent ID from user:** + User provides: `agent-abc123` 2. **List its blocks:** ```bash @@ -109,7 +119,11 @@ Scenario: You're a new agent and want to inherit memory from an existing agent " 3. **Copy project knowledge to yourself:** ```bash + # If you don't have a 'project' block yet: npx ts-node scripts/copy-block.ts --block-id block-def456 + + # If you already have 'project', use --label to rename: + npx ts-node scripts/copy-block.ts --block-id block-def456 --label project-v1 ``` 4. **Optionally share human preferences (read-only):** diff --git a/src/skills/builtin/migrating-memory/scripts/copy-block.ts b/src/skills/builtin/migrating-memory/scripts/copy-block.ts index 18826be..27b527a 100644 --- a/src/skills/builtin/migrating-memory/scripts/copy-block.ts +++ b/src/skills/builtin/migrating-memory/scripts/copy-block.ts @@ -1,12 +1,15 @@ #!/usr/bin/env npx ts-node /** - * Copy Block - Copies a memory block to create a new independent block for another agent + * Copy Block - Copies a memory block to create a new independent block for the current agent * * Usage: - * npx ts-node copy-block.ts --block-id --target-agent-id + * npx ts-node copy-block.ts --block-id [--label ] + * + * Options: + * --label Override the block label (required if you already have a block with that label) * * 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 + * then attaches it to the current agent. Changes to the new block * won't affect the original. * * Output: @@ -30,23 +33,23 @@ interface CopyBlockResult { * 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) + * @param options - Optional settings: labelOverride, targetAgentId * @returns Object containing source block, new block, and attach result */ export async function copyBlock( client: Letta, blockId: string, - targetAgentId?: string, + options?: { labelOverride?: string; targetAgentId?: string }, ): Promise { // Get current agent ID (the agent calling this script) or use provided ID - const currentAgentId = targetAgentId ?? getCurrentAgentId(); + const currentAgentId = options?.targetAgentId ?? getCurrentAgentId(); // 1. Get source block details const sourceBlock = await client.blocks.retrieve(blockId); - // 2. Create new block with same content + // 2. Create new block with same content (optionally override label) const newBlock = await client.blocks.create({ - label: sourceBlock.label || "migrated-block", + label: options?.labelOverride || sourceBlock.label || "migrated-block", value: sourceBlock.value, description: sourceBlock.description || undefined, limit: sourceBlock.limit, @@ -60,8 +63,9 @@ export async function copyBlock( return { sourceBlock, newBlock, attachResult }; } -function parseArgs(args: string[]): { blockId: string } { +function parseArgs(args: string[]): { blockId: string; label?: string } { const blockIdIndex = args.indexOf("--block-id"); + const labelIndex = args.indexOf("--label"); if (blockIdIndex === -1 || blockIdIndex + 1 >= args.length) { throw new Error("Missing required argument: --block-id "); @@ -69,6 +73,10 @@ function parseArgs(args: string[]): { blockId: string } { return { blockId: args[blockIdIndex + 1] as string, + label: + labelIndex !== -1 && labelIndex + 1 < args.length + ? (args[labelIndex + 1] as string) + : undefined, }; } @@ -76,10 +84,10 @@ function parseArgs(args: string[]): { blockId: string } { if (require.main === module) { (async () => { try { - const { blockId } = parseArgs(process.argv.slice(2)); + const { blockId, label } = parseArgs(process.argv.slice(2)); await settingsManager.initialize(); const client = await getClient(); - const result = await copyBlock(client, blockId); + const result = await copyBlock(client, blockId, { labelOverride: label }); console.log(JSON.stringify(result, null, 2)); } catch (error) { console.error( @@ -91,7 +99,7 @@ if (require.main === module) { error.message.includes("Missing required argument") ) { console.error( - "\nUsage: npx ts-node copy-block.ts --block-id ", + "\nUsage: npx ts-node copy-block.ts --block-id [--label ]", ); } 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 deleted file mode 100644 index 7674919..0000000 --- a/src/skills/builtin/migrating-memory/scripts/list-agents.ts +++ /dev/null @@ -1,43 +0,0 @@ -#!/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/skills/builtin/searching-messages/SKILL.md b/src/skills/builtin/searching-messages/SKILL.md new file mode 100644 index 0000000..c087385 --- /dev/null +++ b/src/skills/builtin/searching-messages/SKILL.md @@ -0,0 +1,127 @@ +--- +name: searching-messages +description: Search past messages to recall context. Use when you need to remember previous discussions, find specific topics mentioned before, pull up context from earlier in the conversation history, or find which agent discussed a topic. +--- + +# Searching Messages + +This skill helps you search through past conversations to recall context that may have fallen out of your context window. + +## When to Use This Skill + +- User asks "do you remember when we discussed X?" +- You need context from an earlier conversation +- User references something from the past that you don't have in context +- You want to verify what was said before about a topic +- You need to find which agent discussed a specific topic (use with `finding-agents` skill) + +## Script Usage + +The scripts are located in the `scripts/` subdirectory of this skill. Use the **Skill Directory** path shown above when loading this skill. + +```bash +npx tsx /scripts/search-messages.ts --query [options] +``` + +Replace `` with the actual path from the `# Skill Directory:` line at the top of this loaded skill. + +### Options + +| Option | Description | +|--------|-------------| +| `--query ` | Search query (required) | +| `--mode ` | Search mode: `vector`, `fts`, `hybrid` (default: hybrid) | +| `--start-date ` | Filter messages after this date (ISO format) | +| `--end-date ` | Filter messages before this date (ISO format) | +| `--limit ` | Max results (default: 10) | +| `--all-agents` | Search all agents, not just current agent | +| `--agent-id ` | Explicit agent ID (for manual testing) | + +### Search Modes + +- **hybrid** (default): Combines vector similarity + full-text search with RRF scoring +- **vector**: Semantic similarity search (good for conceptual matches) +- **fts**: Full-text search (good for exact phrases) + +## Companion Script: get-messages.ts + +Use this to expand around a found needle by message ID cursor: + +```bash +npx tsx /scripts/get-messages.ts [options] +``` + +| Option | Description | +|--------|-------------| +| `--after ` | Get messages after this ID (cursor) | +| `--before ` | Get messages before this ID (cursor) | +| `--order ` | Sort order (default: desc = newest first) | +| `--limit ` | Max results (default: 20) | +| `--agent-id ` | Explicit agent ID | + +## Search Strategies + +### Strategy 1: Needle + Expand (Recommended) + +Use when you need full conversation context around a specific topic: + +1. **Find the needle** - Search with keywords to discover relevant messages: + ```bash + npx tsx /scripts/search-messages.ts --query "flicker inline approval" --limit 5 + ``` + +2. **Note the message_id** - Find the most relevant result and copy its `message_id` + +3. **Expand before** - Get messages leading up to the needle: + ```bash + npx tsx /scripts/get-messages.ts --before "message-xyz" --limit 10 + ``` + +4. **Expand after** - Get messages following the needle (use `--order asc` for chronological): + ```bash + npx tsx /scripts/get-messages.ts --after "message-xyz" --order asc --limit 10 + ``` + +### Strategy 2: Date-Bounded Search + +Use when you know approximately when something was discussed: + +```bash +npx tsx /scripts/search-messages.ts --query "topic" --start-date "2025-12-31T00:00:00Z" --end-date "2025-12-31T23:59:59Z" --limit 15 +``` + +Results are sorted by relevance within the date window. + +### Strategy 3: Broad Discovery + +Use when you're not sure what you're looking for: + +```bash +npx tsx /scripts/search-messages.ts --query "vague topic" --mode vector --limit 10 +``` + +Vector mode finds semantically similar messages even without exact keyword matches. + +### Strategy 4: Find Which Agent Discussed Something + +Use with `--all-agents` to search across all agents and identify which one discussed a topic: + +```bash +npx tsx /scripts/search-messages.ts --query "authentication refactor" --all-agents --limit 10 +``` + +Results include `agent_id` for each message. Use this to: +1. Find the agent that worked on a specific feature +2. Identify the right agent to ask follow-up questions +3. Cross-reference with the `finding-agents` skill to get agent details + +**Tip:** Load both `searching-messages` and `finding-agents` skills together when you need to find and identify agents by topic. + +## Search Output + +Returns search results with: +- `message_id` - Use this for cursor-based expansion +- `message_type` - `user_message`, `assistant_message`, `reasoning_message` +- `content` or `reasoning` - The actual message text +- `created_at` - When the message was sent (ISO format) +- `agent_id` - Which agent the message belongs to diff --git a/src/skills/builtin/searching-messages/scripts/get-messages.ts b/src/skills/builtin/searching-messages/scripts/get-messages.ts new file mode 100644 index 0000000..0a2f16c --- /dev/null +++ b/src/skills/builtin/searching-messages/scripts/get-messages.ts @@ -0,0 +1,223 @@ +#!/usr/bin/env npx tsx + +/** + * Get Messages - Retrieve messages from an agent in chronological order + * + * This script is standalone and can be run outside the CLI process. + * It reads auth from LETTA_API_KEY env var or ~/.letta/settings.json. + * It reads agent ID from LETTA_AGENT_ID env var or --agent-id arg. + * + * Usage: + * npx tsx get-messages.ts [options] + * + * Options: + * --start-date Filter messages after this date (ISO format) + * --end-date Filter messages before this date (ISO format) + * --limit Max results (default: 20) + * --agent-id Explicit agent ID (overrides LETTA_AGENT_ID env var) + * --after Cursor: get messages after this ID + * --before Cursor: get messages before this ID + * --order Sort order (default: desc = newest first) + * + * Use this after search-messages.ts to expand around a found needle. + * + * Output: + * Messages in chronological order (filtered by date if specified) + */ + +import { readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import Letta from "@letta-ai/letta-client"; + +interface GetMessagesOptions { + startDate?: string; + endDate?: string; + limit?: number; + agentId?: string; + after?: string; + before?: string; + order?: "asc" | "desc"; +} + +/** + * Get API key from env var or settings file + */ +function getApiKey(): string { + // First check env var (set by CLI's getShellEnv) + if (process.env.LETTA_API_KEY) { + return process.env.LETTA_API_KEY; + } + + // Fall back to settings file + const settingsPath = join(homedir(), ".letta", "settings.json"); + try { + const settings = JSON.parse(readFileSync(settingsPath, "utf-8")); + if (settings.env?.LETTA_API_KEY) { + return settings.env.LETTA_API_KEY; + } + } catch { + // Settings file doesn't exist or is invalid + } + + throw new Error( + "No LETTA_API_KEY found. Set the env var or run the Letta CLI to authenticate.", + ); +} + +/** + * Get agent ID from CLI arg, env var, or throw + */ +function getAgentId(cliArg?: string): string { + // CLI arg takes precedence + if (cliArg) return cliArg; + + // Then env var (set by CLI's getShellEnv) + if (process.env.LETTA_AGENT_ID) { + return process.env.LETTA_AGENT_ID; + } + + throw new Error( + "No agent ID provided. Use --agent-id or ensure LETTA_AGENT_ID env var is set.", + ); +} + +/** + * Create a Letta client with auth from env/settings + */ +function createClient(): Letta { + return new Letta({ apiKey: getApiKey() }); +} + +/** + * Get messages from an agent, optionally filtered by date range + * @param client - Letta client instance + * @param options - Options for filtering + * @returns Array of messages in chronological order + */ +export async function getMessages( + client: Letta, + options: GetMessagesOptions = {}, +): Promise { + const agentId = getAgentId(options.agentId); + const limit = options.limit ?? 20; + + // Fetch messages from the agent + const response = await client.agents.messages.list(agentId, { + limit, + after: options.after, + before: options.before, + order: options.order, + }); + + const messages = response.items ?? []; + + // Client-side date filtering if specified + if (options.startDate || options.endDate) { + const startTime = options.startDate + ? new Date(options.startDate).getTime() + : 0; + const endTime = options.endDate + ? new Date(options.endDate).getTime() + : Number.POSITIVE_INFINITY; + + const filtered = messages.filter((msg) => { + // Messages use 'date' field, not 'created_at' + if (!("date" in msg) || !msg.date) return true; + const msgTime = new Date(msg.date).getTime(); + return msgTime >= startTime && msgTime <= endTime; + }); + + // Sort chronologically (oldest first) + return filtered.sort((a, b) => { + const aDate = "date" in a && a.date ? new Date(a.date).getTime() : 0; + const bDate = "date" in b && b.date ? new Date(b.date).getTime() : 0; + return aDate - bDate; + }); + } + + // Sort chronologically (oldest first) + return [...messages].sort((a, b) => { + const aDate = "date" in a && a.date ? new Date(a.date).getTime() : 0; + const bDate = "date" in b && b.date ? new Date(b.date).getTime() : 0; + return aDate - bDate; + }); +} + +function parseArgs(args: string[]): GetMessagesOptions { + const options: GetMessagesOptions = {}; + + const startDateIndex = args.indexOf("--start-date"); + if (startDateIndex !== -1 && startDateIndex + 1 < args.length) { + options.startDate = args[startDateIndex + 1]; + } + + const endDateIndex = args.indexOf("--end-date"); + if (endDateIndex !== -1 && endDateIndex + 1 < args.length) { + options.endDate = args[endDateIndex + 1]; + } + + const limitIndex = args.indexOf("--limit"); + if (limitIndex !== -1 && limitIndex + 1 < args.length) { + const limit = Number.parseInt(args[limitIndex + 1] as string, 10); + if (!Number.isNaN(limit)) { + options.limit = limit; + } + } + + const agentIdIndex = args.indexOf("--agent-id"); + if (agentIdIndex !== -1 && agentIdIndex + 1 < args.length) { + options.agentId = args[agentIdIndex + 1]; + } + + const afterIndex = args.indexOf("--after"); + if (afterIndex !== -1 && afterIndex + 1 < args.length) { + options.after = args[afterIndex + 1]; + } + + const beforeIndex = args.indexOf("--before"); + if (beforeIndex !== -1 && beforeIndex + 1 < args.length) { + options.before = args[beforeIndex + 1]; + } + + const orderIndex = args.indexOf("--order"); + if (orderIndex !== -1 && orderIndex + 1 < args.length) { + const order = args[orderIndex + 1] as string; + if (order === "asc" || order === "desc") { + options.order = order; + } + } + + return options; +} + +// CLI entry point - check if this file is being run directly +const isMainModule = import.meta.url === `file://${process.argv[1]}`; +if (isMainModule) { + (async () => { + try { + const options = parseArgs(process.argv.slice(2)); + const client = createClient(); + const result = await getMessages(client, options); + console.log(JSON.stringify(result, null, 2)); + } catch (error) { + console.error( + "Error:", + error instanceof Error ? error.message : String(error), + ); + console.error(` +Usage: npx tsx get-messages.ts [options] + +Options: + --after Cursor: get messages after this ID + --before Cursor: get messages before this ID + --order Sort order (default: desc = newest first) + --limit Max results (default: 20) + --agent-id Explicit agent ID (overrides LETTA_AGENT_ID env var) + --start-date Client-side filter: after this date (ISO format) + --end-date Client-side filter: before this date (ISO format) +`); + process.exit(1); + } + })(); +} diff --git a/src/skills/builtin/searching-messages/scripts/search-messages.ts b/src/skills/builtin/searching-messages/scripts/search-messages.ts new file mode 100644 index 0000000..83ac4c1 --- /dev/null +++ b/src/skills/builtin/searching-messages/scripts/search-messages.ts @@ -0,0 +1,193 @@ +#!/usr/bin/env npx tsx + +/** + * Search Messages - Search past conversations with vector/FTS search + * + * This script is standalone and can be run outside the CLI process. + * It reads auth from LETTA_API_KEY env var or ~/.letta/settings.json. + * It reads agent ID from LETTA_AGENT_ID env var or --agent-id arg. + * + * Usage: + * npx tsx search-messages.ts --query [options] + * + * Options: + * --query Search query (required) + * --mode Search mode: vector, fts, hybrid (default: hybrid) + * --start-date Filter messages after this date (ISO format) + * --end-date Filter messages before this date (ISO format) + * --limit Max results (default: 10) + * --all-agents Search all agents, not just current agent + * --agent-id Explicit agent ID (overrides LETTA_AGENT_ID env var) + * + * Output: + * Raw API response with search results + */ + +import { readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import Letta from "@letta-ai/letta-client"; + +interface SearchMessagesOptions { + query: string; + mode?: "vector" | "fts" | "hybrid"; + startDate?: string; + endDate?: string; + limit?: number; + allAgents?: boolean; + agentId?: string; +} + +/** + * Get API key from env var or settings file + */ +function getApiKey(): string { + // First check env var (set by CLI's getShellEnv) + if (process.env.LETTA_API_KEY) { + return process.env.LETTA_API_KEY; + } + + // Fall back to settings file + const settingsPath = join(homedir(), ".letta", "settings.json"); + try { + const settings = JSON.parse(readFileSync(settingsPath, "utf-8")); + if (settings.env?.LETTA_API_KEY) { + return settings.env.LETTA_API_KEY; + } + } catch { + // Settings file doesn't exist or is invalid + } + + throw new Error( + "No LETTA_API_KEY found. Set the env var or run the Letta CLI to authenticate.", + ); +} + +/** + * Get agent ID from CLI arg, env var, or throw + */ +function getAgentId(cliArg?: string): string { + // CLI arg takes precedence + if (cliArg) return cliArg; + + // Then env var (set by CLI's getShellEnv) + if (process.env.LETTA_AGENT_ID) { + return process.env.LETTA_AGENT_ID; + } + + throw new Error( + "No agent ID provided. Use --agent-id or ensure LETTA_AGENT_ID env var is set.", + ); +} + +/** + * Create a Letta client with auth from env/settings + */ +function createClient(): Letta { + return new Letta({ apiKey: getApiKey() }); +} + +/** + * Search messages in past conversations + * @param client - Letta client instance + * @param options - Search options + * @returns Array of search results with scores + */ +export async function searchMessages( + client: Letta, + options: SearchMessagesOptions, +): Promise>> { + // Default to current agent unless --all-agents is specified + let agentId: string | undefined; + if (!options.allAgents) { + agentId = getAgentId(options.agentId); + } + + return await client.messages.search({ + query: options.query, + agent_id: agentId, + search_mode: options.mode ?? "hybrid", + start_date: options.startDate, + end_date: options.endDate, + limit: options.limit ?? 10, + }); +} + +function parseArgs(args: string[]): SearchMessagesOptions { + const queryIndex = args.indexOf("--query"); + if (queryIndex === -1 || queryIndex + 1 >= args.length) { + throw new Error("Missing required argument: --query "); + } + + const options: SearchMessagesOptions = { + query: args[queryIndex + 1] as string, + }; + + const modeIndex = args.indexOf("--mode"); + if (modeIndex !== -1 && modeIndex + 1 < args.length) { + const mode = args[modeIndex + 1] as string; + if (mode === "vector" || mode === "fts" || mode === "hybrid") { + options.mode = mode; + } + } + + const startDateIndex = args.indexOf("--start-date"); + if (startDateIndex !== -1 && startDateIndex + 1 < args.length) { + options.startDate = args[startDateIndex + 1]; + } + + const endDateIndex = args.indexOf("--end-date"); + if (endDateIndex !== -1 && endDateIndex + 1 < args.length) { + options.endDate = args[endDateIndex + 1]; + } + + const limitIndex = args.indexOf("--limit"); + if (limitIndex !== -1 && limitIndex + 1 < args.length) { + const limit = Number.parseInt(args[limitIndex + 1] as string, 10); + if (!Number.isNaN(limit)) { + options.limit = limit; + } + } + + if (args.includes("--all-agents")) { + options.allAgents = true; + } + + const agentIdIndex = args.indexOf("--agent-id"); + if (agentIdIndex !== -1 && agentIdIndex + 1 < args.length) { + options.agentId = args[agentIdIndex + 1]; + } + + return options; +} + +// CLI entry point - check if this file is being run directly +const isMainModule = import.meta.url === `file://${process.argv[1]}`; +if (isMainModule) { + (async () => { + try { + const options = parseArgs(process.argv.slice(2)); + const client = createClient(); + const result = await searchMessages(client, options); + console.log(JSON.stringify(result, null, 2)); + } catch (error) { + console.error( + "Error:", + error instanceof Error ? error.message : String(error), + ); + console.error(` +Usage: npx tsx search-messages.ts --query [options] + +Options: + --query Search query (required) + --mode Search mode: vector, fts, hybrid (default: hybrid) + --start-date Filter messages after this date (ISO format) + --end-date Filter messages before this date (ISO format) + --limit Max results (default: 10) + --all-agents Search all agents, not just current agent + --agent-id Explicit agent ID (overrides LETTA_AGENT_ID env var) +`); + process.exit(1); + } + })(); +} diff --git a/src/tests/skills/finding-agents-scripts.test.ts b/src/tests/skills/finding-agents-scripts.test.ts new file mode 100644 index 0000000..ccea0e6 --- /dev/null +++ b/src/tests/skills/finding-agents-scripts.test.ts @@ -0,0 +1,147 @@ +/** + * Tests for the bundled finding-agents scripts + */ + +import { describe, expect, mock, test } from "bun:test"; +import type Letta from "@letta-ai/letta-client"; +import { findAgents } from "../../skills/builtin/finding-agents/scripts/find-agents"; + +// Mock data +const mockAgentsResponse = [ + { id: "agent-123", name: "Test Agent 1", tags: ["origin:letta-code"] }, + { id: "agent-456", name: "Test Agent 2", tags: ["frontend"] }, +]; + +describe("find-agents", () => { + test("calls client.agents.list with default options", async () => { + const mockList = mock(() => Promise.resolve(mockAgentsResponse)); + const mockClient = { + agents: { + list: mockList, + }, + } as unknown as Letta; + + const result = await findAgents(mockClient); + + expect(mockList).toHaveBeenCalledWith({ limit: 20 }); + expect(result).toBeDefined(); + }); + + test("passes name filter", async () => { + const mockList = mock(() => Promise.resolve(mockAgentsResponse)); + const mockClient = { + agents: { + list: mockList, + }, + } as unknown as Letta; + + await findAgents(mockClient, { name: "Test Agent" }); + + expect(mockList).toHaveBeenCalledWith({ + limit: 20, + name: "Test Agent", + }); + }); + + test("passes query_text for fuzzy search", async () => { + const mockList = mock(() => Promise.resolve(mockAgentsResponse)); + const mockClient = { + agents: { + list: mockList, + }, + } as unknown as Letta; + + await findAgents(mockClient, { query: "test" }); + + expect(mockList).toHaveBeenCalledWith({ + limit: 20, + query_text: "test", + }); + }); + + test("passes tags filter", async () => { + const mockList = mock(() => Promise.resolve(mockAgentsResponse)); + const mockClient = { + agents: { + list: mockList, + }, + } as unknown as Letta; + + await findAgents(mockClient, { tags: ["origin:letta-code", "frontend"] }); + + expect(mockList).toHaveBeenCalledWith({ + limit: 20, + tags: ["origin:letta-code", "frontend"], + }); + }); + + test("passes match_all_tags when specified", async () => { + const mockList = mock(() => Promise.resolve(mockAgentsResponse)); + const mockClient = { + agents: { + list: mockList, + }, + } as unknown as Letta; + + await findAgents(mockClient, { + tags: ["origin:letta-code", "frontend"], + matchAllTags: true, + }); + + expect(mockList).toHaveBeenCalledWith({ + limit: 20, + tags: ["origin:letta-code", "frontend"], + match_all_tags: true, + }); + }); + + test("includes agent.blocks when specified", async () => { + const mockList = mock(() => Promise.resolve(mockAgentsResponse)); + const mockClient = { + agents: { + list: mockList, + }, + } as unknown as Letta; + + await findAgents(mockClient, { includeBlocks: true }); + + expect(mockList).toHaveBeenCalledWith({ + limit: 20, + include: ["agent.blocks"], + }); + }); + + test("respects custom limit", async () => { + const mockList = mock(() => Promise.resolve(mockAgentsResponse)); + const mockClient = { + agents: { + list: mockList, + }, + } as unknown as Letta; + + await findAgents(mockClient, { limit: 5 }); + + expect(mockList).toHaveBeenCalledWith({ limit: 5 }); + }); + + test("handles empty results", async () => { + const mockClient = { + agents: { + list: mock(() => Promise.resolve([])), + }, + } as unknown as Letta; + + const result = await findAgents(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(findAgents(mockClient)).rejects.toThrow("API Error"); + }); +}); diff --git a/src/tests/skills/memory-migration-scripts.test.ts b/src/tests/skills/memory-migration-scripts.test.ts index 2b41546..c281676 100644 --- a/src/tests/skills/memory-migration-scripts.test.ts +++ b/src/tests/skills/memory-migration-scripts.test.ts @@ -7,14 +7,8 @@ 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", @@ -59,42 +53,6 @@ const mockAgentState = { 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)); @@ -158,7 +116,9 @@ describe("copy-block", () => { } 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"); + const result = await copyBlock(mockClient, "block-abc", { + targetAgentId: "agent-789", + }); expect(mockRetrieve).toHaveBeenCalledWith("block-abc"); expect(mockCreate).toHaveBeenCalledWith({ @@ -176,6 +136,33 @@ describe("copy-block", () => { expect(result.attachResult).toBeDefined(); }); + test("supports label override", async () => { + const mockCreate = mock(() => Promise.resolve(mockNewBlock)); + const mockClient = { + blocks: { + retrieve: mock(() => Promise.resolve(mockBlock)), + create: mockCreate, + }, + agents: { + blocks: { + attach: mock(() => Promise.resolve(mockAgentState)), + }, + }, + } as unknown as Letta; + + await copyBlock(mockClient, "block-abc", { + labelOverride: "project-imported", + targetAgentId: "agent-789", + }); + + expect(mockCreate).toHaveBeenCalledWith({ + label: "project-imported", + value: "Test project content", + description: "Project info", + limit: 5000, + }); + }); + test("handles block without description", async () => { const blockWithoutDesc = { ...mockBlock, description: null }; const mockClient = { @@ -190,7 +177,9 @@ describe("copy-block", () => { }, } as unknown as Letta; - const result = await copyBlock(mockClient, "block-abc", "agent-789"); + const result = await copyBlock(mockClient, "block-abc", { + targetAgentId: "agent-789", + }); expect(result.newBlock).toBeDefined(); }); @@ -208,7 +197,7 @@ describe("copy-block", () => { } as unknown as Letta; await expect( - copyBlock(mockClient, "nonexistent", "agent-789"), + copyBlock(mockClient, "nonexistent", { targetAgentId: "agent-789" }), ).rejects.toThrow("Block not found"); }); }); diff --git a/src/tests/skills/searching-messages-scripts.test.ts b/src/tests/skills/searching-messages-scripts.test.ts new file mode 100644 index 0000000..ecf15ba --- /dev/null +++ b/src/tests/skills/searching-messages-scripts.test.ts @@ -0,0 +1,399 @@ +/** + * Tests for the bundled searching-messages scripts + */ + +import { describe, expect, mock, test } from "bun:test"; +import type Letta from "@letta-ai/letta-client"; +import { getMessages } from "../../skills/builtin/searching-messages/scripts/get-messages"; +import { searchMessages } from "../../skills/builtin/searching-messages/scripts/search-messages"; + +// Mock data for search results +const mockSearchResponse = [ + { + message_type: "assistant_message", + content: "This is a test message about flicker", + message_id: "message-123", + agent_id: "agent-456", + created_at: "2025-12-31T03:09:59.273101Z", + }, + { + message_type: "user_message", + content: "Do you remember when we discussed flicker?", + message_id: "message-789", + agent_id: "agent-456", + created_at: "2025-12-31T03:08:00.000000Z", + }, +]; + +// Mock data for messages list +const mockMessagesResponse = { + items: [ + { + id: "message-001", + date: "2025-12-31T03:10:00+00:00", + message_type: "user_message", + content: "First message", + }, + { + id: "message-002", + date: "2025-12-31T03:11:00+00:00", + message_type: "assistant_message", + content: "Second message", + }, + ], +}; + +describe("search-messages", () => { + test("calls client.messages.search with query and defaults", async () => { + const mockSearch = mock(() => Promise.resolve(mockSearchResponse)); + const mockClient = { + messages: { + search: mockSearch, + }, + } as unknown as Letta; + + const result = await searchMessages(mockClient, { + query: "flicker", + agentId: "agent-456", + }); + + expect(mockSearch).toHaveBeenCalledWith({ + query: "flicker", + agent_id: "agent-456", + search_mode: "hybrid", + start_date: undefined, + end_date: undefined, + limit: 10, + }); + expect(result).toBeDefined(); + expect(result).toHaveLength(2); + }); + + test("passes search mode option", async () => { + const mockSearch = mock(() => Promise.resolve(mockSearchResponse)); + const mockClient = { + messages: { + search: mockSearch, + }, + } as unknown as Letta; + + await searchMessages(mockClient, { + query: "test", + mode: "vector", + agentId: "agent-456", + }); + + expect(mockSearch).toHaveBeenCalledWith( + expect.objectContaining({ + search_mode: "vector", + }), + ); + }); + + test("passes date filters", async () => { + const mockSearch = mock(() => Promise.resolve(mockSearchResponse)); + const mockClient = { + messages: { + search: mockSearch, + }, + } as unknown as Letta; + + await searchMessages(mockClient, { + query: "test", + startDate: "2025-12-31T00:00:00Z", + endDate: "2025-12-31T23:59:59Z", + agentId: "agent-456", + }); + + expect(mockSearch).toHaveBeenCalledWith( + expect.objectContaining({ + start_date: "2025-12-31T00:00:00Z", + end_date: "2025-12-31T23:59:59Z", + }), + ); + }); + + test("omits agent_id when allAgents is true", async () => { + const mockSearch = mock(() => Promise.resolve(mockSearchResponse)); + const mockClient = { + messages: { + search: mockSearch, + }, + } as unknown as Letta; + + await searchMessages(mockClient, { + query: "test", + allAgents: true, + }); + + expect(mockSearch).toHaveBeenCalledWith( + expect.objectContaining({ + agent_id: undefined, + }), + ); + }); + + test("respects custom limit", async () => { + const mockSearch = mock(() => Promise.resolve(mockSearchResponse)); + const mockClient = { + messages: { + search: mockSearch, + }, + } as unknown as Letta; + + await searchMessages(mockClient, { + query: "test", + limit: 5, + agentId: "agent-456", + }); + + expect(mockSearch).toHaveBeenCalledWith( + expect.objectContaining({ + limit: 5, + }), + ); + }); + + test("handles empty results", async () => { + const mockClient = { + messages: { + search: mock(() => Promise.resolve([])), + }, + } as unknown as Letta; + + const result = await searchMessages(mockClient, { + query: "nonexistent", + agentId: "agent-456", + }); + expect(result).toBeDefined(); + expect(result).toHaveLength(0); + }); + + test("propagates API errors", async () => { + const mockClient = { + messages: { + search: mock(() => Promise.reject(new Error("API Error"))), + }, + } as unknown as Letta; + + await expect( + searchMessages(mockClient, { query: "test", agentId: "agent-456" }), + ).rejects.toThrow("API Error"); + }); +}); + +describe("get-messages", () => { + test("calls client.agents.messages.list with defaults", async () => { + const mockList = mock(() => Promise.resolve(mockMessagesResponse)); + const mockClient = { + agents: { + messages: { + list: mockList, + }, + }, + } as unknown as Letta; + + const result = await getMessages(mockClient, { agentId: "agent-456" }); + + expect(mockList).toHaveBeenCalledWith("agent-456", { + limit: 20, + after: undefined, + before: undefined, + order: undefined, + }); + expect(result).toBeDefined(); + }); + + test("passes after cursor", async () => { + const mockList = mock(() => Promise.resolve(mockMessagesResponse)); + const mockClient = { + agents: { + messages: { + list: mockList, + }, + }, + } as unknown as Letta; + + await getMessages(mockClient, { + agentId: "agent-456", + after: "message-123", + }); + + expect(mockList).toHaveBeenCalledWith( + "agent-456", + expect.objectContaining({ + after: "message-123", + }), + ); + }); + + test("passes before cursor", async () => { + const mockList = mock(() => Promise.resolve(mockMessagesResponse)); + const mockClient = { + agents: { + messages: { + list: mockList, + }, + }, + } as unknown as Letta; + + await getMessages(mockClient, { + agentId: "agent-456", + before: "message-789", + }); + + expect(mockList).toHaveBeenCalledWith( + "agent-456", + expect.objectContaining({ + before: "message-789", + }), + ); + }); + + test("passes order option", async () => { + const mockList = mock(() => Promise.resolve(mockMessagesResponse)); + const mockClient = { + agents: { + messages: { + list: mockList, + }, + }, + } as unknown as Letta; + + await getMessages(mockClient, { + agentId: "agent-456", + order: "asc", + }); + + expect(mockList).toHaveBeenCalledWith( + "agent-456", + expect.objectContaining({ + order: "asc", + }), + ); + }); + + test("respects custom limit", async () => { + const mockList = mock(() => Promise.resolve(mockMessagesResponse)); + const mockClient = { + agents: { + messages: { + list: mockList, + }, + }, + } as unknown as Letta; + + await getMessages(mockClient, { + agentId: "agent-456", + limit: 50, + }); + + expect(mockList).toHaveBeenCalledWith( + "agent-456", + expect.objectContaining({ + limit: 50, + }), + ); + }); + + test("filters by date range client-side", async () => { + const mockList = mock(() => + Promise.resolve({ + items: [ + { + id: "msg-1", + date: "2025-12-30T12:00:00Z", + message_type: "user_message", + }, + { + id: "msg-2", + date: "2025-12-31T12:00:00Z", + message_type: "user_message", + }, + { + id: "msg-3", + date: "2026-01-01T12:00:00Z", + message_type: "user_message", + }, + ], + }), + ); + const mockClient = { + agents: { + messages: { + list: mockList, + }, + }, + } as unknown as Letta; + + const result = await getMessages(mockClient, { + agentId: "agent-456", + startDate: "2025-12-31T00:00:00Z", + endDate: "2025-12-31T23:59:59Z", + }); + + // Should filter to only the message from Dec 31 + expect(result).toHaveLength(1); + expect((result[0] as { id: string }).id).toBe("msg-2"); + }); + + test("sorts results chronologically", async () => { + const mockList = mock(() => + Promise.resolve({ + items: [ + { + id: "msg-2", + date: "2025-12-31T14:00:00Z", + message_type: "user_message", + }, + { + id: "msg-1", + date: "2025-12-31T12:00:00Z", + message_type: "user_message", + }, + ], + }), + ); + const mockClient = { + agents: { + messages: { + list: mockList, + }, + }, + } as unknown as Letta; + + const result = await getMessages(mockClient, { agentId: "agent-456" }); + + // Should be sorted oldest first + expect((result[0] as { id: string }).id).toBe("msg-1"); + expect((result[1] as { id: string }).id).toBe("msg-2"); + }); + + test("handles empty results", async () => { + const mockClient = { + agents: { + messages: { + list: mock(() => Promise.resolve({ items: [] })), + }, + }, + } as unknown as Letta; + + const result = await getMessages(mockClient, { agentId: "agent-456" }); + expect(result).toBeDefined(); + expect(result).toHaveLength(0); + }); + + test("propagates API errors", async () => { + const mockClient = { + agents: { + messages: { + list: mock(() => Promise.reject(new Error("API Error"))), + }, + }, + } as unknown as Letta; + + await expect( + getMessages(mockClient, { agentId: "agent-456" }), + ).rejects.toThrow("API Error"); + }); +}); diff --git a/src/tools/impl/Skill.ts b/src/tools/impl/Skill.ts index 798a197..3ae9da4 100644 --- a/src/tools/impl/Skill.ts +++ b/src/tools/impl/Skill.ts @@ -270,7 +270,9 @@ export async function skill(args: SkillArgs): Promise { const skillsToProcess = skillIds as string[]; if (command === "load") { - // Load skills + // Load skills - track which ones were prepared successfully + const preparedSkills: string[] = []; + for (const skillId of skillsToProcess) { if (loadedSkillIds.includes(skillId)) { results.push(`"${skillId}" already loaded`); @@ -288,15 +290,21 @@ export async function skill(args: SkillArgs): Promise { // Build skill header with optional path info const skillDir = dirname(skillPath); - const pathLine = hasAdditionalFiles(skillPath) + const hasExtras = hasAdditionalFiles(skillPath); + const pathLine = hasExtras ? `# Skill Directory: ${skillDir}\n\n` : ""; + // Replace placeholder with actual path in skill content + const processedContent = hasExtras + ? skillContent.replace(//g, skillDir) + : skillContent; + // Append new skill const separator = currentValue ? "\n\n---\n\n" : ""; - currentValue = `${currentValue}${separator}# Skill: ${skillId}\n${pathLine}${skillContent}`; + currentValue = `${currentValue}${separator}# Skill: ${skillId}\n${pathLine}${processedContent}`; loadedSkillIds.push(skillId); - results.push(`"${skillId}" loaded`); + preparedSkills.push(skillId); } catch (error) { results.push( `"${skillId}" failed: ${error instanceof Error ? error.message : String(error)}`, @@ -304,14 +312,19 @@ export async function skill(args: SkillArgs): Promise { } } - // Update the block - await client.agents.blocks.update("loaded_skills", { - agent_id: agentId, - value: currentValue, - }); + // Update the block - only report success AFTER the update succeeds + if (preparedSkills.length > 0) { + await client.agents.blocks.update("loaded_skills", { + agent_id: agentId, + value: currentValue, + }); - // Update the cached flag - if (loadedSkillIds.length > 0) { + // Now we can report success + for (const skillId of preparedSkills) { + results.push(`"${skillId}" loaded`); + } + + // Update the cached flag setHasLoadedSkills(true); } } else { diff --git a/src/tools/impl/shellEnv.ts b/src/tools/impl/shellEnv.ts index 3e17f4a..76d7a68 100644 --- a/src/tools/impl/shellEnv.ts +++ b/src/tools/impl/shellEnv.ts @@ -1,12 +1,14 @@ /** * Shell environment utilities * Provides enhanced environment variables for shell execution, - * including bundled tools like ripgrep in PATH. + * including bundled tools like ripgrep in PATH and Letta context for skill scripts. */ import { createRequire } from "node:module"; import * as path from "node:path"; import { fileURLToPath } from "node:url"; +import { getCurrentAgentId } from "../../agent/context"; +import { settingsManager } from "../../settings-manager"; /** * Get the directory containing the bundled ripgrep binary. @@ -26,7 +28,7 @@ function getRipgrepBinDir(): string | undefined { /** * Get enhanced environment variables for shell execution. - * Includes bundled tools (like ripgrep) in PATH. + * Includes bundled tools (like ripgrep) in PATH and Letta context for skill scripts. */ export function getShellEnv(): NodeJS.ProcessEnv { const env = { ...process.env }; @@ -38,5 +40,24 @@ export function getShellEnv(): NodeJS.ProcessEnv { env.PATH = `${rgBinDir}${path.delimiter}${currentPath}`; } + // Add Letta context for skill scripts + try { + env.LETTA_AGENT_ID = getCurrentAgentId(); + } catch { + // Context not set yet (e.g., during startup), skip + } + + // Inject API key from settings if not already in env + if (!env.LETTA_API_KEY) { + try { + const settings = settingsManager.getSettings(); + if (settings.env?.LETTA_API_KEY) { + env.LETTA_API_KEY = settings.env.LETTA_API_KEY; + } + } catch { + // Settings not initialized yet, skip + } + } + return env; }