From ee7cc92724f1cd2b3b705e2f6cbe504d633d50c2 Mon Sep 17 00:00:00 2001 From: cpacker Date: Tue, 27 Jan 2026 00:30:09 -0800 Subject: [PATCH] Initial release of Letta Code SDK MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Programmatic control of Letta Code CLI with persistent agent memory. Features: - createSession() / resumeSession() / prompt() API - resumeConversation() for multi-threaded conversations - Multi-turn conversations with memory - Tool execution (Bash, Read, Edit, etc.) - System prompt and memory configuration - Permission callbacks (canUseTool) - Message streaming with typed events šŸ‘¾ Generated with [Letta Code](https://letta.com) --- .github/workflows/ci.yml | 63 +++ .github/workflows/release.yml | 149 ++++++ .gitignore | 7 + LICENSE | 190 ++++++++ README.md | 328 ++++++++++++++ build.ts | 40 ++ examples/v2-examples.ts | 822 ++++++++++++++++++++++++++++++++++ package.json | 40 ++ src/index.ts | 159 +++++++ src/session.ts | 358 +++++++++++++++ src/transport.ts | 342 ++++++++++++++ src/types.ts | 252 +++++++++++ src/validation.ts | 112 +++++ tsconfig.build.json | 12 + tsconfig.json | 25 ++ 15 files changed, 2899 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/release.yml create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 build.ts create mode 100644 examples/v2-examples.ts create mode 100644 package.json create mode 100644 src/index.ts create mode 100644 src/session.ts create mode 100644 src/transport.ts create mode 100644 src/types.ts create mode 100644 src/validation.ts create mode 100644 tsconfig.build.json create mode 100644 tsconfig.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..6187520 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,63 @@ +name: CI + +on: + pull_request: + branches: [main] + push: + branches: [main] + +jobs: + check: + name: Lint & Type Check + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v1 + with: + bun-version: 1.3.0 + + - name: Install dependencies + run: bun install + + - name: Lint & Type Check + run: bun run check + + build: + needs: check + name: Build & Test + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v1 + with: + bun-version: 1.3.0 + + - name: Install dependencies + run: bun install + + - name: Build + run: bun run build + + - name: SDK smoke test + # Only run with API key on push or same-repo PRs + if: ${{ github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) }} + env: + LETTA_API_KEY: ${{ secrets.LETTA_API_KEY }} + run: bun examples/v2-examples.ts basic + + - name: SDK full test + # Only run with API key on push or same-repo PRs + if: ${{ github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) }} + env: + LETTA_API_KEY: ${{ secrets.LETTA_API_KEY }} + run: bun examples/v2-examples.ts all diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..157af09 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,149 @@ +name: Bump version and release + +on: + workflow_dispatch: + inputs: + version_type: + description: "Version bump type (patch, minor, major)" + required: false + default: "patch" + prerelease: + description: "Publish as prerelease? (leave empty for stable, or enter tag like 'next')" + required: false + default: "" + +jobs: + publish: + runs-on: ubuntu-latest + environment: npm-publish + permissions: + contents: write + id-token: write # Required for npm OIDC trusted publishing + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + token: ${{ secrets.GITHUB_TOKEN }} + fetch-depth: 0 + + - name: Configure Git + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + - name: Setup Bun + uses: oven-sh/setup-bun@v1 + with: + bun-version: 1.3.0 + + - name: Setup Node (for npm OIDC publishing) + uses: actions/setup-node@v4 + with: + node-version: "22" + + - name: Upgrade npm for OIDC support + run: npm install -g npm@latest + + - name: Bump version + id: version + run: | + VERSION_TYPE="${{ github.event.inputs.version_type || 'patch' }}" + PRERELEASE_TAG="${{ github.event.inputs.prerelease }}" + OLD_VERSION=$(jq -r '.version' package.json) + + # Check if old version is a prerelease (contains -) + if [[ "$OLD_VERSION" == *-* ]]; then + OLD_IS_PRERELEASE=true + BASE_VERSION=$(echo "$OLD_VERSION" | sed 's/-.*//') + else + OLD_IS_PRERELEASE=false + BASE_VERSION="$OLD_VERSION" + fi + + # Split base version into parts + IFS='.' read -ra VERSION_PARTS <<< "$BASE_VERSION" + MAJOR=${VERSION_PARTS[0]} + MINOR=${VERSION_PARTS[1]} + PATCH=${VERSION_PARTS[2]} + + # Always bump based on version_type + if [ "$VERSION_TYPE" = "major" ]; then + MAJOR=$((MAJOR + 1)) + MINOR=0 + PATCH=0 + elif [ "$VERSION_TYPE" = "minor" ]; then + MINOR=$((MINOR + 1)) + PATCH=0 + else + # patch - only bump if not coming from prerelease + # (prerelease → stable with patch just drops the suffix) + if [ "$OLD_IS_PRERELEASE" = "false" ]; then + PATCH=$((PATCH + 1)) + fi + fi + + NEW_VERSION="${MAJOR}.${MINOR}.${PATCH}" + + # Handle prerelease suffix + if [ -n "$PRERELEASE_TAG" ]; then + # Making a prerelease + if [[ "$OLD_VERSION" == "${NEW_VERSION}-${PRERELEASE_TAG}."* ]]; then + # Same base + same tag: increment prerelease number + PRERELEASE_NUM=$(echo "$OLD_VERSION" | sed "s/${NEW_VERSION}-${PRERELEASE_TAG}\.\([0-9]*\)/\1/") + PRERELEASE_NUM=$((PRERELEASE_NUM + 1)) + else + # Different base or different tag: start at 1 + PRERELEASE_NUM=1 + fi + NEW_VERSION="${NEW_VERSION}-${PRERELEASE_TAG}.${PRERELEASE_NUM}" + echo "npm_tag=$PRERELEASE_TAG" >> $GITHUB_OUTPUT + echo "is_prerelease=true" >> $GITHUB_OUTPUT + else + # Making a stable release - NEW_VERSION is already just the base + echo "npm_tag=latest" >> $GITHUB_OUTPUT + echo "is_prerelease=false" >> $GITHUB_OUTPUT + fi + + # Update package.json + jq --arg version "$NEW_VERSION" '.version = $version' package.json > package.json.tmp + mv package.json.tmp package.json + + echo "old_version=$OLD_VERSION" >> $GITHUB_OUTPUT + echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT + echo "tag=v$NEW_VERSION" >> $GITHUB_OUTPUT + + - name: Commit and push version bump + run: | + git add package.json + git commit -m "chore: bump version to ${{ steps.version.outputs.new_version }} [skip ci]" + git tag "${{ steps.version.outputs.tag }}" + git push origin main + git push origin "${{ steps.version.outputs.tag }}" + + - name: Install dependencies + run: bun install + + - name: Lint & Type Check + run: bun run check + + - name: Build + run: bun run build + + - name: SDK smoke test + env: + LETTA_API_KEY: ${{ secrets.LETTA_API_KEY }} + run: bun examples/v2-examples.ts basic + + - name: Create GitHub Release + if: steps.version.outputs.is_prerelease == 'false' + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ steps.version.outputs.tag }} + name: Release ${{ steps.version.outputs.tag }} + body: | + Release ${{ steps.version.outputs.tag }} + + Changes from ${{ steps.version.outputs.old_version }} to ${{ steps.version.outputs.new_version }} + + - name: Publish to npm + run: npm publish --access public --tag ${{ steps.version.outputs.npm_tag }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..50886a3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +node_modules/ +dist/ +bun.lockb +bun.lock +*.log +.DS_Store +.letta/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f9839f4 --- /dev/null +++ b/LICENSE @@ -0,0 +1,190 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + Copyright 2025, Letta authors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md new file mode 100644 index 0000000..0c2c255 --- /dev/null +++ b/README.md @@ -0,0 +1,328 @@ +# Letta Code SDK + +[![npm](https://img.shields.io/npm/v/@letta-ai/letta-code-sdk.svg?style=flat-square)](https://www.npmjs.com/package/@letta-ai/letta-code-sdk) + +The SDK interface to [Letta Code](https://github.com/letta-ai/letta-code). Build agents with persistent memory that learn over time. + +```typescript +import { prompt } from '@letta-ai/letta-code-sdk'; + +const result = await prompt('Find and fix the bug in auth.py', { + allowedTools: ['Read', 'Edit', 'Bash'], + permissionMode: 'bypassPermissions' +}); +console.log(result.result); +``` + +## Installation + +```bash +npm install @letta-ai/letta-code-sdk +``` + +## Quick Start + +### One-shot prompt + +```typescript +import { prompt } from '@letta-ai/letta-code-sdk'; + +const result = await prompt('Run: echo hello', { + allowedTools: ['Bash'], + permissionMode: 'bypassPermissions' +}); +console.log(result.result); // "hello" +``` + +### Multi-turn session + +```typescript +import { createSession } from '@letta-ai/letta-code-sdk'; + +await using session = createSession(); + +await session.send('What is 5 + 3?'); +for await (const msg of session.stream()) { + if (msg.type === 'assistant') console.log(msg.content); +} + +await session.send('Multiply that by 2'); +for await (const msg of session.stream()) { + if (msg.type === 'assistant') console.log(msg.content); +} +``` + +### Persistent memory + +Agents persist across sessions and remember context: + +```typescript +import { createSession, resumeSession } from '@letta-ai/letta-code-sdk'; + +// First session +const session1 = createSession(); +await session1.send('Remember: the secret word is "banana"'); +for await (const msg of session1.stream()) { /* ... */ } +const agentId = session1.agentId; +session1.close(); + +// Later... +await using session2 = resumeSession(agentId); +await session2.send('What is the secret word?'); +for await (const msg of session2.stream()) { + if (msg.type === 'assistant') console.log(msg.content); // "banana" +} +``` + +### Multi-threaded Conversations + +Run multiple concurrent conversations with the same agent. Each conversation has its own message history while sharing the agent's persistent memory. + +```typescript +import { createSession, resumeSession, resumeConversation } from '@letta-ai/letta-code-sdk'; + +// Create an agent +const session = createSession(); +await session.send('Hello!'); +for await (const msg of session.stream()) { /* ... */ } +const agentId = session.agentId; +const conversationId = session.conversationId; // Save this! +session.close(); + +// Resume a specific conversation +await using session2 = resumeConversation(conversationId); +await session2.send('Continue our discussion...'); +for await (const msg of session2.stream()) { /* ... */ } + +// Create a NEW conversation on the same agent +await using session3 = resumeSession(agentId, { newConversation: true }); +await session3.send('Start a fresh thread...'); +// session3.conversationId is different from conversationId + +// Resume with agent's default conversation +await using session4 = resumeSession(agentId, { defaultConversation: true }); + +// Resume last used session (agent + conversation) +await using session5 = createSession({ continue: true }); +``` + +**Key concepts:** +- **Agent** (`agentId`): Persistent entity with memory that survives across sessions +- **Conversation** (`conversationId`): A message thread within an agent +- **Session** (`sessionId`): A single execution/connection + +Agents remember across conversations (via memory blocks), but each conversation has its own message history. + +## Agent Configuration + +### System Prompt + +Choose from built-in presets or provide a custom prompt: + +```typescript +// Use a preset +createSession({ + systemPrompt: { type: 'preset', preset: 'letta-claude' } +}); + +// Use a preset with additional instructions +createSession({ + systemPrompt: { + type: 'preset', + preset: 'letta-claude', + append: 'Always respond in Spanish.' + } +}); + +// Use a completely custom prompt +createSession({ + systemPrompt: 'You are a helpful Python expert.' +}); +``` + +**Available presets:** +- `default` / `letta-claude` - Full Letta Code prompt (Claude-optimized) +- `letta-codex` - Full Letta Code prompt (Codex-optimized) +- `letta-gemini` - Full Letta Code prompt (Gemini-optimized) +- `claude` - Basic Claude (no skills/memory instructions) +- `codex` - Basic Codex +- `gemini` - Basic Gemini + +### Memory Blocks + +Configure which memory blocks the agent uses: + +```typescript +// Use default blocks (persona, human, project) +createSession({}); + +// Use specific preset blocks +createSession({ + memory: ['project', 'persona'] // Only these blocks +}); + +// Use custom blocks +createSession({ + memory: [ + { label: 'context', value: 'API documentation for Acme Corp...' }, + { label: 'rules', value: 'Always use TypeScript. Prefer functional patterns.' } + ] +}); + +// Mix presets and custom blocks +createSession({ + memory: [ + 'project', // Use default project block + { label: 'custom', value: 'Additional context...' } + ] +}); + +// No optional blocks (only core skills blocks) +createSession({ + memory: [] +}); +``` + +### Convenience Props + +Quickly customize common memory blocks: + +```typescript +createSession({ + persona: 'You are a senior Python developer who writes clean, tested code.', + human: 'Name: Alice. Prefers concise responses.', + project: 'FastAPI backend for a todo app using PostgreSQL.' +}); + +// Combine with memory config +createSession({ + memory: ['persona', 'project'], // Only include these blocks + persona: 'You are a Go expert.', + project: 'CLI tool for managing Docker containers.' +}); +``` + +### Tool Execution + +Execute tools with automatic permission handling: + +```typescript +import { prompt } from '@letta-ai/letta-code-sdk'; + +// Run shell commands +const result = await prompt('List all TypeScript files', { + allowedTools: ['Glob', 'Bash'], + permissionMode: 'bypassPermissions', + cwd: '/path/to/project' +}); + +// Read and analyze code +const analysis = await prompt('Explain what auth.ts does', { + allowedTools: ['Read', 'Grep'], + permissionMode: 'bypassPermissions' +}); +``` + +## API Reference + +### Functions + +| Function | Description | +|----------|-------------| +| `prompt(message, options?)` | One-shot query, returns result directly | +| `createSession(options?)` | Create new agent session | +| `resumeSession(agentId, options?)` | Resume existing agent by ID | +| `resumeConversation(conversationId, options?)` | Resume specific conversation (derives agent automatically) | + +### Session + +| Property/Method | Description | +|-----------------|-------------| +| `send(message)` | Send user message | +| `stream()` | AsyncGenerator yielding messages | +| `close()` | Close the session | +| `agentId` | Agent ID (for resuming later) | +| `sessionId` | Current session ID | +| `conversationId` | Conversation ID (for resuming specific thread) | + +### Options + +```typescript +interface SessionOptions { + // Model selection + model?: string; + + // Conversation options + conversationId?: string; // Resume specific conversation + newConversation?: boolean; // Create new conversation on agent + continue?: boolean; // Resume last session (agent + conversation) + defaultConversation?: boolean; // Use agent's default conversation + + // System prompt: string or preset config + systemPrompt?: string | { + type: 'preset'; + preset: 'default' | 'letta-claude' | 'letta-codex' | 'letta-gemini' | 'claude' | 'codex' | 'gemini'; + append?: string; + }; + + // Memory blocks: preset names, custom blocks, or mixed + memory?: Array; + + // Convenience: set block values directly + persona?: string; + human?: string; + project?: string; + + // Tool configuration + allowedTools?: string[]; + permissionMode?: 'default' | 'acceptEdits' | 'bypassPermissions'; + + // Working directory + cwd?: string; +} +``` + +### Message Types + +```typescript +// Streamed during receive() +interface SDKAssistantMessage { + type: 'assistant'; + content: string; + uuid: string; +} + +// Final message +interface SDKResultMessage { + type: 'result'; + success: boolean; + result?: string; + error?: string; + durationMs: number; + conversationId: string; +} +``` + +## Examples + +See [`examples/`](./examples/) for comprehensive examples including: + +- Basic session usage +- Multi-turn conversations +- Session resume with persistent memory +- **Multi-threaded conversations** (resumeConversation, newConversation) +- System prompt configuration +- Memory block customization +- Tool execution (Bash, Glob, Read, etc.) + +Run examples: +```bash +bun examples/v2-examples.ts all + +# Run just conversation tests +bun examples/v2-examples.ts conversations +``` + +## License + +Apache-2.0 diff --git a/build.ts b/build.ts new file mode 100644 index 0000000..381601e --- /dev/null +++ b/build.ts @@ -0,0 +1,40 @@ +#!/usr/bin/env bun + +/** + * Build script for Letta Code SDK + * Bundles TypeScript source and generates declarations + */ + +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +// Read version from package.json +const pkg = JSON.parse(readFileSync(join(__dirname, "package.json"), "utf-8")); +const version = pkg.version; + +console.log(`šŸ“¦ Building Letta Code SDK v${version}...`); + +// Bundle with Bun +await Bun.build({ + entrypoints: ["./src/index.ts"], + outdir: "./dist", + target: "node", + format: "esm", + minify: false, + sourcemap: "external", +}); + +// Generate type declarations +console.log("šŸ“ Generating type declarations..."); +const tscResult = Bun.spawnSync(["bunx", "tsc", "-p", "tsconfig.build.json"]); +if (tscResult.exitCode !== 0) { + console.error("Type generation failed:", tscResult.stderr.toString()); + process.exit(1); +} + +console.log("āœ… Build complete!"); +console.log(` Output: dist/`); diff --git a/examples/v2-examples.ts b/examples/v2-examples.ts new file mode 100644 index 0000000..874a0d6 --- /dev/null +++ b/examples/v2-examples.ts @@ -0,0 +1,822 @@ +#!/usr/bin/env bun + +/** + * Letta Code SDK V2 Examples + * + * Comprehensive tests for all SDK features. + * Parallel to Claude Agent SDK V2 examples. + * + * Run with: LETTA_CLI_PATH=path/to/letta.js bun v2-examples.ts [example] + */ + +import { createSession, resumeSession, resumeConversation, prompt } from '../src/index.js'; + +const CLI_PATH = process.env.LETTA_CLI_PATH; +if (!CLI_PATH) { + console.error('Set LETTA_CLI_PATH environment variable'); + process.exit(1); +} + +async function main() { + const example = process.argv[2] || 'basic'; + + switch (example) { + case 'basic': + await basicSession(); + break; + case 'multi-turn': + await multiTurn(); + break; + case 'one-shot': + await oneShot(); + break; + case 'resume': + await sessionResume(); + break; + case 'options': + await testOptions(); + break; + case 'message-types': + await testMessageTypes(); + break; + case 'session-properties': + await testSessionProperties(); + break; + case 'tool-execution': + await testToolExecution(); + break; + case 'permission-callback': + await testPermissionCallback(); + break; + case 'system-prompt': + await testSystemPrompt(); + break; + case 'memory-config': + await testMemoryConfig(); + break; + case 'convenience-props': + await testConvenienceProps(); + break; + case 'conversations': + await testConversations(); + break; + case 'all': + await basicSession(); + await multiTurn(); + await oneShot(); + await sessionResume(); + await testOptions(); + await testMessageTypes(); + await testSessionProperties(); + await testToolExecution(); + await testPermissionCallback(); + await testSystemPrompt(); + await testMemoryConfig(); + await testConvenienceProps(); + await testConversations(); + console.log('\nāœ… All examples passed'); + break; + default: + console.log('Usage: bun v2-examples.ts [basic|multi-turn|one-shot|resume|options|message-types|session-properties|tool-execution|permission-callback|system-prompt|memory-config|convenience-props|conversations|all]'); + } +} + +// ═══════════════════════════════════════════════════════════════ +// BASIC EXAMPLES +// ═══════════════════════════════════════════════════════════════ + +// Basic session with send/receive pattern +async function basicSession() { + console.log('=== Basic Session ===\n'); + + await using session = createSession({ + model: 'haiku', + permissionMode: 'bypassPermissions', + }); + + await session.send('Hello! Introduce yourself in one sentence.'); + + let response = ''; + for await (const msg of session.stream()) { + if (msg.type === 'assistant') { + response += msg.content; + } + if (msg.type === 'result') { + console.log(`Letta: ${response.trim()}`); + console.log(`[Result: ${msg.success ? 'success' : 'failed'}, ${msg.durationMs}ms]`); + } + } + console.log(); +} + +// Multi-turn conversation +async function multiTurn() { + console.log('=== Multi-Turn Conversation ===\n'); + + await using session = createSession({ + model: 'haiku', + permissionMode: 'bypassPermissions', + }); + + // Turn 1 + await session.send('What is 5 + 3? Just the number.'); + let turn1Result = ''; + for await (const msg of session.stream()) { + if (msg.type === 'assistant') turn1Result += msg.content; + } + console.log(`Turn 1: ${turn1Result.trim()}`); + + // Turn 2 - agent remembers context + await session.send('Multiply that by 2. Just the number.'); + let turn2Result = ''; + for await (const msg of session.stream()) { + if (msg.type === 'assistant') turn2Result += msg.content; + } + console.log(`Turn 2: ${turn2Result.trim()}`); + console.log(); +} + +// One-shot convenience function +async function oneShot() { + console.log('=== One-Shot Prompt ===\n'); + + const result = await prompt('What is the capital of France? One word.', { + model: 'haiku', + permissionMode: 'bypassPermissions', + }); + + if (result.success) { + console.log(`Answer: ${result.result}`); + console.log(`Duration: ${result.durationMs}ms`); + } else { + console.log(`Error: ${result.error}`); + } + console.log(); +} + +// Session resume - with PERSISTENT MEMORY +async function sessionResume() { + console.log('=== Session Resume (Persistent Memory) ===\n'); + + let agentId: string | null = null; + + // First session - establish a memory + { + const session = createSession({ + model: 'haiku', + permissionMode: 'bypassPermissions', + }); + + console.log('[Session 1] Teaching agent a secret word...'); + await session.send('Remember this secret word: "pineapple". Store it in your memory.'); + + let response = ''; + for await (const msg of session.stream()) { + if (msg.type === 'assistant') response += msg.content; + if (msg.type === 'result') { + console.log(`[Session 1] Agent: ${response.trim()}`); + } + } + + agentId = session.agentId; + console.log(`[Session 1] Agent ID: ${agentId}\n`); + session.close(); + } + + console.log('--- Session closed. Agent persists on server. ---\n'); + + // Resume and verify agent remembers + { + await using session = resumeSession(agentId!, { + permissionMode: 'bypassPermissions', + }); + + console.log('[Session 2] Asking agent for the secret word...'); + await session.send('What is the secret word I told you to remember?'); + + let response = ''; + for await (const msg of session.stream()) { + if (msg.type === 'assistant') response += msg.content; + if (msg.type === 'result') { + console.log(`[Session 2] Agent: ${response.trim()}`); + } + } + } + console.log(); +} + +// ═══════════════════════════════════════════════════════════════ +// OPTIONS TESTS +// ═══════════════════════════════════════════════════════════════ + +async function testOptions() { + console.log('=== Testing Options ===\n'); + + // Test model option + console.log('Testing model option...'); + const modelResult = await prompt('Say "model test ok"', { + model: 'haiku', + permissionMode: 'bypassPermissions', + }); + console.log(` model: ${modelResult.success ? 'PASS' : 'FAIL'} - ${modelResult.result?.slice(0, 50)}`); + + // Test systemPrompt option + console.log('Testing systemPrompt option...'); + const sysPromptResult = await prompt('Tell me a fun fact about penguins in one sentence.', { + model: 'haiku', + systemPrompt: 'You love penguins and always try to work penguin facts into conversations.', + permissionMode: 'bypassPermissions', + }); + const hasPenguin = sysPromptResult.result?.toLowerCase().includes('penguin'); + console.log(` systemPrompt: ${hasPenguin ? 'PASS' : 'PARTIAL'} - ${sysPromptResult.result?.slice(0, 80)}`); + + // Test cwd option + console.log('Testing cwd option...'); + const cwdResult = await prompt('Run pwd to show current directory', { + model: 'haiku', + cwd: '/tmp', + allowedTools: ['Bash'], + permissionMode: 'bypassPermissions', + }); + const hasTmp = cwdResult.result?.includes('/tmp'); + console.log(` cwd: ${hasTmp ? 'PASS' : 'CHECK'} - ${cwdResult.result?.slice(0, 60)}`); + + // Test allowedTools option with tool execution + console.log('Testing allowedTools option...'); + const toolsResult = await prompt('Run: echo tool-test-ok', { + model: 'haiku', + allowedTools: ['Bash'], + permissionMode: 'bypassPermissions', + }); + const hasToolOutput = toolsResult.result?.includes('tool-test-ok'); + console.log(` allowedTools: ${hasToolOutput ? 'PASS' : 'CHECK'} - ${toolsResult.result?.slice(0, 60)}`); + + // Test permissionMode: bypassPermissions + console.log('Testing permissionMode: bypassPermissions...'); + const bypassResult = await prompt('Run: echo bypass-test', { + model: 'haiku', + allowedTools: ['Bash'], + permissionMode: 'bypassPermissions', + }); + const hasBypassOutput = bypassResult.result?.includes('bypass-test'); + console.log(` permissionMode: ${hasBypassOutput ? 'PASS' : 'CHECK'}`); + + console.log(); +} + +// ═══════════════════════════════════════════════════════════════ +// MESSAGE TYPES TESTS +// ═══════════════════════════════════════════════════════════════ + +async function testMessageTypes() { + console.log('=== Testing Message Types ===\n'); + + const session = createSession({ + model: 'haiku', + permissionMode: 'bypassPermissions', + }); + + await session.send('Say "hello" exactly'); + + let sawAssistant = false; + let sawResult = false; + let assistantContent = ''; + + for await (const msg of session.stream()) { + if (msg.type === 'assistant') { + sawAssistant = true; + assistantContent += msg.content; + // Verify assistant message has uuid + if (!msg.uuid) { + console.log(' assistant.uuid: FAIL - missing uuid'); + } + } + if (msg.type === 'result') { + sawResult = true; + // Verify result message structure + const hasSuccess = typeof msg.success === 'boolean'; + const hasDuration = typeof msg.durationMs === 'number'; + console.log(` result.success: ${hasSuccess ? 'PASS' : 'FAIL'}`); + console.log(` result.durationMs: ${hasDuration ? 'PASS' : 'FAIL'}`); + console.log(` result.result: ${msg.result ? 'PASS' : 'FAIL (empty)'}`); + } + } + + console.log(` assistant message received: ${sawAssistant ? 'PASS' : 'FAIL'}`); + console.log(` result message received: ${sawResult ? 'PASS' : 'FAIL'}`); + + session.close(); + console.log(); +} + +// ═══════════════════════════════════════════════════════════════ +// SESSION PROPERTIES TESTS +// ═══════════════════════════════════════════════════════════════ + +async function testSessionProperties() { + console.log('=== Testing Session Properties ===\n'); + + const session = createSession({ + model: 'haiku', + permissionMode: 'bypassPermissions', + }); + + // Before send - properties should be null + console.log(` agentId before send: ${session.agentId === null ? 'PASS (null)' : 'FAIL'}`); + console.log(` sessionId before send: ${session.sessionId === null ? 'PASS (null)' : 'FAIL'}`); + + await session.send('Hi'); + for await (const _ of session.stream()) { + // drain + } + + // After send - properties should be set + const hasAgentId = session.agentId !== null && session.agentId.startsWith('agent-'); + const hasSessionId = session.sessionId !== null; + console.log(` agentId after send: ${hasAgentId ? 'PASS' : 'FAIL'} - ${session.agentId}`); + console.log(` sessionId after send: ${hasSessionId ? 'PASS' : 'FAIL'} - ${session.sessionId}`); + + // Test close() + session.close(); + console.log(` close(): PASS (no error)`); + + console.log(); +} + +// ═══════════════════════════════════════════════════════════════ +// TOOL EXECUTION TESTS +// ═══════════════════════════════════════════════════════════════ + +async function testToolExecution() { + console.log('=== Testing Tool Execution ===\n'); + + // Test 1: Basic command execution + console.log('Testing basic command execution...'); + const echoResult = await prompt('Run: echo hello-world', { + model: 'haiku', + allowedTools: ['Bash'], + permissionMode: 'bypassPermissions', + }); + const hasHello = echoResult.result?.includes('hello-world'); + console.log(` echo command: ${hasHello ? 'PASS' : 'FAIL'}`); + + // Test 2: Command with arguments + console.log('Testing command with arguments...'); + const argsResult = await prompt('Run: echo "arg1 arg2 arg3"', { + model: 'haiku', + allowedTools: ['Bash'], + permissionMode: 'bypassPermissions', + }); + const hasArgs = argsResult.result?.includes('arg1') && argsResult.result?.includes('arg3'); + console.log(` echo with args: ${hasArgs ? 'PASS' : 'FAIL'}`); + + // Test 3: File reading with Glob + console.log('Testing Glob tool...'); + const globResult = await prompt('List all .ts files in the current directory using Glob', { + model: 'haiku', + allowedTools: ['Glob'], + permissionMode: 'bypassPermissions', + }); + console.log(` Glob tool: ${globResult.success ? 'PASS' : 'FAIL'}`); + + // Test 4: Multi-step tool usage (agent decides which tools to use) + console.log('Testing multi-step tool usage...'); + const multiResult = await prompt('First run "echo step1", then run "echo step2". Show me both outputs.', { + model: 'haiku', + allowedTools: ['Bash'], + permissionMode: 'bypassPermissions', + }); + const hasStep1 = multiResult.result?.includes('step1'); + const hasStep2 = multiResult.result?.includes('step2'); + console.log(` multi-step: ${hasStep1 && hasStep2 ? 'PASS' : 'PARTIAL'} (step1: ${hasStep1}, step2: ${hasStep2})`); + + console.log(); +} + +// ═══════════════════════════════════════════════════════════════ +// PERMISSION CALLBACK TESTS +// ═══════════════════════════════════════════════════════════════ + +async function testPermissionCallback() { + console.log('=== Testing Permission Callback ===\n'); + + // Note: permissionMode 'default' with NO allowedTools triggers callback + // allowedTools auto-allows tools, bypassing the callback + + // Test 1: Allow specific commands via callback + console.log('Testing canUseTool callback (allow)...'); + const allowResult = await prompt('Run: echo callback-allowed', { + model: 'haiku', + // NO allowedTools - this ensures callback is invoked + permissionMode: 'default', + canUseTool: async (toolName, toolInput) => { + console.error('CALLBACK:', toolName, toolInput); + const command = (toolInput as { command?: string }).command || ''; + if (command.includes('callback-allowed')) { + return { allow: true, reason: 'Command whitelisted' }; + } + return { allow: false, reason: 'Command not whitelisted' }; + }, + }); + const hasAllowed = allowResult.result?.includes('callback-allowed'); + console.log(` allow via callback: ${hasAllowed ? 'PASS' : 'FAIL'}`); + + // Test 2: Deny specific commands via callback + console.log('Testing canUseTool callback (deny)...'); + const denyResult = await prompt('Run: echo dangerous-command', { + model: 'haiku', + permissionMode: 'default', + canUseTool: async (toolName, toolInput) => { + const command = (toolInput as { command?: string }).command || ''; + if (command.includes('dangerous')) { + return { allow: false, reason: 'Dangerous command blocked' }; + } + return { allow: true }; + }, + }); + // Agent should report that it couldn't execute the command + const wasDenied = !denyResult.result?.includes('dangerous-command'); + console.log(` deny via callback: ${wasDenied ? 'PASS' : 'CHECK'}`); + + console.log(); +} + +// ═══════════════════════════════════════════════════════════════ +// SYSTEM PROMPT TESTS +// ═══════════════════════════════════════════════════════════════ + +async function testSystemPrompt() { + console.log('=== Testing System Prompt Configuration ===\n'); + + // Test 1: Preset system prompt + console.log('Testing preset system prompt...'); + const presetResult = await prompt('What kind of agent are you? One sentence.', { + model: 'haiku', + systemPrompt: { type: 'preset', preset: 'letta-claude' }, + permissionMode: 'bypassPermissions', + }); + console.log(` preset (letta-claude): ${presetResult.success ? 'PASS' : 'FAIL'}`); + console.log(` Response: ${presetResult.result?.slice(0, 80)}...`); + + // Test 2: Preset with append + console.log('Testing preset with append...'); + const appendResult = await prompt('Say hello', { + model: 'haiku', + systemPrompt: { + type: 'preset', + preset: 'letta-claude', + append: 'Always end your responses with "šŸŽ‰"' + }, + permissionMode: 'bypassPermissions', + }); + const hasEmoji = appendResult.result?.includes('šŸŽ‰'); + console.log(` preset with append: ${hasEmoji ? 'PASS' : 'CHECK'}`); + console.log(` Response: ${appendResult.result?.slice(0, 80)}...`); + + // Test 3: Custom string system prompt + console.log('Testing custom string system prompt...'); + const customResult = await prompt('What is your specialty?', { + model: 'haiku', + systemPrompt: 'You are a pirate captain. Always speak like a pirate.', + permissionMode: 'bypassPermissions', + }); + const hasPirateSpeak = customResult.result?.toLowerCase().includes('arr') || + customResult.result?.toLowerCase().includes('matey') || + customResult.result?.toLowerCase().includes('ship'); + console.log(` custom string: ${customResult.success ? 'PASS' : 'FAIL'}`); + console.log(` Response: ${customResult.result?.slice(0, 80)}...`); + + // Test 4: Basic preset (claude - no skills/memory) + console.log('Testing basic preset (claude)...'); + const basicResult = await prompt('Hello, just say hi back', { + model: 'haiku', + systemPrompt: { type: 'preset', preset: 'claude' }, + permissionMode: 'bypassPermissions', + }); + console.log(` basic preset (claude): ${basicResult.success ? 'PASS' : 'FAIL'}`); + + console.log(); +} + +// ═══════════════════════════════════════════════════════════════ +// MEMORY CONFIGURATION TESTS +// ═══════════════════════════════════════════════════════════════ + +async function testMemoryConfig() { + console.log('=== Testing Memory Configuration ===\n'); + + // Test 1: Default memory (persona, human, project) + console.log('Testing default memory blocks...'); + const defaultResult = await prompt('What memory blocks do you have? List their labels.', { + model: 'haiku', + permissionMode: 'bypassPermissions', + }); + const hasDefaultBlocks = defaultResult.result?.includes('persona') || + defaultResult.result?.includes('project'); + console.log(` default blocks: ${defaultResult.success ? 'PASS' : 'FAIL'}`); + console.log(` Response mentions blocks: ${hasDefaultBlocks ? 'yes' : 'check manually'}`); + + // Test 2: Specific preset blocks only + console.log('Testing specific preset blocks...'); + const specificResult = await prompt('List your memory block labels', { + model: 'haiku', + memory: ['project'], + permissionMode: 'bypassPermissions', + }); + console.log(` specific blocks [project]: ${specificResult.success ? 'PASS' : 'FAIL'}`); + + // Test 3: Custom blocks + console.log('Testing custom memory blocks...'); + const customResult = await prompt('What does your "rules" memory block say?', { + model: 'haiku', + memory: [ + { label: 'rules', value: 'Always be concise. Never use more than 10 words.' } + ], + permissionMode: 'bypassPermissions', + }); + const isConcise = (customResult.result?.split(' ').length || 0) < 20; + console.log(` custom blocks: ${customResult.success ? 'PASS' : 'FAIL'}`); + console.log(` Response is concise: ${isConcise ? 'yes' : 'check'}`); + + // Test 4: Mixed preset and custom blocks + console.log('Testing mixed blocks (preset + custom)...'); + const mixedResult = await prompt('List your memory blocks', { + model: 'haiku', + memory: [ + 'project', + { label: 'custom-context', value: 'This is a test context block.' } + ], + permissionMode: 'bypassPermissions', + }); + console.log(` mixed blocks: ${mixedResult.success ? 'PASS' : 'FAIL'}`); + + // Test 5: Empty memory (core blocks only) + console.log('Testing empty memory (core only)...'); + const emptyResult = await prompt('Hello', { + model: 'haiku', + memory: [], + permissionMode: 'bypassPermissions', + }); + console.log(` empty memory: ${emptyResult.success ? 'PASS' : 'FAIL'}`); + + console.log(); +} + +// ═══════════════════════════════════════════════════════════════ +// CONVENIENCE PROPS TESTS +// ═══════════════════════════════════════════════════════════════ + +async function testConvenienceProps() { + console.log('=== Testing Convenience Props ===\n'); + + // Test 1: persona prop + console.log('Testing persona prop...'); + const personaResult = await prompt('Describe your personality in one sentence', { + model: 'haiku', + persona: 'You are an enthusiastic cooking assistant who loves Italian food.', + permissionMode: 'bypassPermissions', + }); + const hasItalian = personaResult.result?.toLowerCase().includes('italian') || + personaResult.result?.toLowerCase().includes('cook'); + console.log(` persona: ${personaResult.success ? 'PASS' : 'FAIL'}`); + console.log(` Response mentions cooking/Italian: ${hasItalian ? 'yes' : 'check'}`); + + // Test 2: project prop + console.log('Testing project prop...'); + const projectResult = await prompt('What project are you helping with?', { + model: 'haiku', + project: 'A React Native mobile app for tracking daily habits.', + permissionMode: 'bypassPermissions', + }); + const hasProject = projectResult.result?.toLowerCase().includes('react') || + projectResult.result?.toLowerCase().includes('habit') || + projectResult.result?.toLowerCase().includes('mobile'); + console.log(` project: ${projectResult.success ? 'PASS' : 'FAIL'}`); + console.log(` Response mentions project: ${hasProject ? 'yes' : 'check'}`); + + // Test 3: human prop + console.log('Testing human prop...'); + const humanResult = await prompt('What do you know about me?', { + model: 'haiku', + human: 'Name: Bob. Senior developer. Prefers TypeScript over JavaScript.', + permissionMode: 'bypassPermissions', + }); + const hasHuman = humanResult.result?.toLowerCase().includes('bob') || + humanResult.result?.toLowerCase().includes('typescript'); + console.log(` human: ${humanResult.success ? 'PASS' : 'FAIL'}`); + console.log(` Response mentions user info: ${hasHuman ? 'yes' : 'check'}`); + + // Test 4: Multiple convenience props together + console.log('Testing multiple convenience props...'); + const multiResult = await prompt('Introduce yourself and the project briefly', { + model: 'haiku', + persona: 'You are a friendly code reviewer.', + project: 'FastAPI backend service.', + human: 'Name: Alice.', + permissionMode: 'bypassPermissions', + }); + console.log(` multiple props: ${multiResult.success ? 'PASS' : 'FAIL'}`); + console.log(` Response: ${multiResult.result?.slice(0, 100)}...`); + + // Test 5: Convenience props with specific memory blocks + console.log('Testing convenience props with memory config...'); + const combinedResult = await prompt('What is in your persona block?', { + model: 'haiku', + memory: ['persona', 'project'], + persona: 'You are a database expert specializing in PostgreSQL.', + permissionMode: 'bypassPermissions', + }); + const hasDB = combinedResult.result?.toLowerCase().includes('database') || + combinedResult.result?.toLowerCase().includes('postgresql'); + console.log(` props with memory: ${combinedResult.success ? 'PASS' : 'FAIL'}`); + console.log(` Response mentions DB: ${hasDB ? 'yes' : 'check'}`); + + console.log(); +} + +// ═══════════════════════════════════════════════════════════════ +// CONVERSATION TESTS +// ═══════════════════════════════════════════════════════════════ + +async function testConversations() { + console.log('=== Testing Conversation Support ===\n'); + + let agentId: string | null = null; + let conversationId1: string | null = null; + let conversationId2: string | null = null; + + // Test 1: Create session and get conversationId (default) + console.log('Test 1: Create session and get conversationId...'); + { + const session = createSession({ + model: 'haiku', + permissionMode: 'bypassPermissions', + }); + + await session.send('Remember: the secret code is ALPHA. Store this in memory.'); + for await (const msg of session.stream()) { + // drain + } + + agentId = session.agentId; + conversationId1 = session.conversationId; + + const hasAgentId = agentId !== null && agentId.startsWith('agent-'); + const hasConvId = conversationId1 !== null; + + console.log(` agentId: ${hasAgentId ? 'PASS' : 'FAIL'} - ${agentId}`); + console.log(` conversationId: ${hasConvId ? 'PASS' : 'FAIL'} - ${conversationId1}`); + + // Note: "default" is a sentinel meaning the agent's primary message history + if (conversationId1 === 'default') { + console.log(' (conversationId "default" = agent\'s primary history, not a real conversation ID)'); + } + + session.close(); + } + + // Test 2: Create NEW conversation to get a real conversation ID + console.log('\nTest 2: Create new conversation (newConversation: true)...'); + { + const session = resumeSession(agentId!, { + newConversation: true, + permissionMode: 'bypassPermissions', + }); + + await session.send('Remember: the secret code for THIS conversation is BETA.'); + for await (const msg of session.stream()) { + // drain + } + + conversationId1 = session.conversationId; + + const isRealConvId = conversationId1 !== null && conversationId1 !== 'default'; + console.log(` newConversation created: ${isRealConvId ? 'PASS' : 'FAIL'}`); + console.log(` conversationId: ${conversationId1}`); + + session.close(); + } + + // Test 3: Resume conversation by conversationId (only works with real conv IDs) + console.log('\nTest 3: Resume conversation by conversationId...'); + if (conversationId1 === 'default') { + console.log(' SKIP - "default" is not a real conversation ID'); + console.log(' Use resumeSession(agentId) to resume default conversation'); + } else { + await using session = resumeConversation(conversationId1!, { + permissionMode: 'bypassPermissions', + }); + + await session.send('What is the secret code for this conversation?'); + + let response = ''; + for await (const msg of session.stream()) { + if (msg.type === 'assistant') response += msg.content; + } + + const remembers = response.toLowerCase().includes('beta'); + console.log(` resumeConversation: ${remembers ? 'PASS' : 'FAIL'}`); + console.log(` Response: ${response.slice(0, 80)}...`); + + // Verify same conversationId + const sameConv = session.conversationId === conversationId1; + console.log(` same conversationId: ${sameConv ? 'PASS' : 'FAIL'}`); + } + + // Test 4: Create another new conversation (verify different IDs) + console.log('\nTest 4: Create another new conversation...'); + { + await using session = resumeSession(agentId!, { + newConversation: true, + permissionMode: 'bypassPermissions', + }); + + await session.send('Say "third conversation"'); + + let response = ''; + for await (const msg of session.stream()) { + if (msg.type === 'assistant') response += msg.content; + } + + conversationId2 = session.conversationId; + + // New conversation should have different ID + const differentConv = conversationId2 !== conversationId1; + console.log(` different from conversationId1: ${differentConv ? 'PASS' : 'FAIL'}`); + console.log(` conversationId1: ${conversationId1}`); + console.log(` conversationId2: ${conversationId2}`); + } + + // Test 5: defaultConversation option + console.log('\nTest 5: defaultConversation option...'); + { + await using session = resumeSession(agentId!, { + defaultConversation: true, + permissionMode: 'bypassPermissions', + }); + + await session.send('Say "default conversation test ok"'); + + let response = ''; + for await (const msg of session.stream()) { + if (msg.type === 'assistant') response += msg.content; + } + + const hasDefaultConv = session.conversationId === 'default' || session.conversationId !== null; + console.log(` defaultConversation: ${hasDefaultConv ? 'PASS' : 'CHECK'}`); + console.log(` conversationId: ${session.conversationId}`); + } + + // Test 6: conversationId in result message + console.log('\nTest 6: conversationId in result message...'); + { + await using session = resumeConversation(conversationId1!, { + permissionMode: 'bypassPermissions', + }); + + await session.send('Hi'); + + let resultConvId: string | null = null; + for await (const msg of session.stream()) { + if (msg.type === 'result') { + resultConvId = msg.conversationId; + } + } + + const hasResultConvId = resultConvId !== null; + const matchesSession = resultConvId === session.conversationId; + console.log(` result.conversationId: ${hasResultConvId ? 'PASS' : 'FAIL'}`); + console.log(` matches session.conversationId: ${matchesSession ? 'PASS' : 'FAIL'}`); + } + + // Test 7: continue option (resume last session) + console.log('\nTest 7: continue option...'); + { + // Note: This test may behave differently depending on local state + // The --continue flag resumes the last used agent + conversation + try { + await using session = createSession({ + continue: true, + permissionMode: 'bypassPermissions', + }); + + await session.send('Say "continue test ok"'); + + for await (const msg of session.stream()) { + // drain + } + + const hasIds = session.agentId !== null && session.conversationId !== null; + console.log(` continue: ${hasIds ? 'PASS' : 'CHECK'}`); + console.log(` agentId: ${session.agentId}`); + console.log(` conversationId: ${session.conversationId}`); + } catch (err) { + // --continue may fail if no previous session exists + console.log(` continue: SKIP (no previous session)`); + } + } + + console.log(); +} + +main().catch(console.error); diff --git a/package.json b/package.json new file mode 100644 index 0000000..d31ed74 --- /dev/null +++ b/package.json @@ -0,0 +1,40 @@ +{ + "name": "@letta-ai/letta-code-sdk", + "version": "0.0.1", + "description": "SDK for programmatic control of Letta Code CLI", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "files": [ + "dist", + "README.md" + ], + "scripts": { + "build": "bun run build.ts", + "dev": "bun run build.ts --watch", + "check": "tsc --noEmit", + "test": "bun test" + }, + "repository": { + "type": "git", + "url": "https://github.com/letta-ai/letta-code-sdk" + }, + "dependencies": { + "@letta-ai/letta-code": "latest" + }, + "devDependencies": { + "@types/bun": "latest", + "@types/node": "^22.0.0", + "typescript": "^5.7.0" + }, + "publishConfig": { + "access": "public" + }, + "license": "Apache-2.0" +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..3299ca3 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,159 @@ +/** + * Letta Code SDK + * + * Programmatic control of Letta Code CLI with persistent agent memory. + * + * @example + * ```typescript + * import { createSession, prompt } from '@letta-ai/letta-code-sdk'; + * + * // One-shot + * const result = await prompt('What is 2+2?', { model: 'claude-sonnet-4-20250514' }); + * + * // Multi-turn session + * await using session = createSession({ model: 'claude-sonnet-4-20250514' }); + * await session.send('Hello!'); + * for await (const msg of session.stream()) { + * if (msg.type === 'assistant') console.log(msg.content); + * } + * + * // Resume with persistent memory + * await using resumed = resumeSession(agentId, { model: 'claude-sonnet-4-20250514' }); + * ``` + */ + +import { Session } from "./session.js"; +import type { SessionOptions, SDKMessage, SDKResultMessage } from "./types.js"; + +// Re-export types +export type { + SessionOptions, + SDKMessage, + SDKInitMessage, + SDKAssistantMessage, + SDKToolCallMessage, + SDKToolResultMessage, + SDKReasoningMessage, + SDKResultMessage, + SDKStreamEventMessage, + PermissionMode, + PermissionResult, + CanUseToolCallback, +} from "./types.js"; + +export { Session } from "./session.js"; + +/** + * Create a new session with a fresh Letta agent. + * + * The agent will have persistent memory that survives across sessions. + * Use `resumeSession` to continue a conversation with an existing agent. + * + * @example + * ```typescript + * await using session = createSession({ model: 'claude-sonnet-4-20250514' }); + * await session.send('My name is Alice'); + * for await (const msg of session.stream()) { + * console.log(msg); + * } + * console.log(`Agent ID: ${session.agentId}`); // Save this to resume later + * ``` + */ +export function createSession(options: SessionOptions = {}): Session { + return new Session(options); +} + +/** + * Resume an existing session with a Letta agent. + * + * Unlike Claude Agent SDK (ephemeral sessions), Letta agents have persistent + * memory. You can resume a conversation days later and the agent will remember. + * + * @example + * ```typescript + * // Days later... + * await using session = resumeSession(agentId, { model: 'claude-sonnet-4-20250514' }); + * await session.send('What is my name?'); + * for await (const msg of session.stream()) { + * // Agent remembers: "Your name is Alice" + * } + * ``` + */ +export function resumeSession( + agentId: string, + options: SessionOptions = {} +): Session { + return new Session({ ...options, agentId }); +} + +/** + * Resume an existing conversation. + * + * Conversations are threads within an agent. The agent is derived automatically + * from the conversation ID. Use this to continue a specific conversation thread. + * + * @example + * ```typescript + * // Resume a specific conversation + * await using session = resumeConversation(conversationId); + * await session.send('Continue our discussion...'); + * for await (const msg of session.stream()) { + * console.log(msg); + * } + * ``` + */ +export function resumeConversation( + conversationId: string, + options: SessionOptions = {} +): Session { + return new Session({ ...options, conversationId }); +} + +/** + * One-shot prompt convenience function. + * + * Creates a session, sends the prompt, collects the response, and closes. + * Returns the final result message. + * + * @example + * ```typescript + * const result = await prompt('What is the capital of France?', { + * model: 'claude-sonnet-4-20250514' + * }); + * if (result.success) { + * console.log(result.result); + * } + * ``` + */ +export async function prompt( + message: string, + options: SessionOptions = {} +): Promise { + const session = createSession(options); + + try { + await session.send(message); + + let result: SDKResultMessage | null = null; + for await (const msg of session.stream()) { + if (msg.type === "result") { + result = msg; + break; + } + } + + if (!result) { + return { + type: "result", + success: false, + error: "No result received", + durationMs: 0, + conversationId: session.conversationId, + }; + } + + return result; + } finally { + session.close(); + } +} diff --git a/src/session.ts b/src/session.ts new file mode 100644 index 0000000..e6f2629 --- /dev/null +++ b/src/session.ts @@ -0,0 +1,358 @@ +/** + * Session + * + * Represents a conversation session with a Letta agent. + * Implements the V2 API pattern: send() / receive() + */ + +import { SubprocessTransport } from "./transport.js"; +import type { + SessionOptions, + SDKMessage, + SDKInitMessage, + SDKAssistantMessage, + SDKResultMessage, + WireMessage, + ControlRequest, + CanUseToolControlRequest, + CanUseToolResponse, + CanUseToolResponseAllow, + CanUseToolResponseDeny, +} from "./types.js"; +import { validateSessionOptions } from "./validation.js"; + +export class Session implements AsyncDisposable { + private transport: SubprocessTransport; + private _agentId: string | null = null; + private _sessionId: string | null = null; + private _conversationId: string | null = null; + private initialized = false; + + constructor( + private options: SessionOptions & { agentId?: string } = {} + ) { + // Validate options before creating transport + validateSessionOptions(options); + this.transport = new SubprocessTransport(options); + } + + /** + * Initialize the session (called automatically on first send) + */ + async initialize(): Promise { + if (this.initialized) { + throw new Error("Session already initialized"); + } + + await this.transport.connect(); + + // Send initialize control request + await this.transport.write({ + type: "control_request", + request_id: "init_1", + request: { subtype: "initialize" }, + }); + + // Wait for init message + for await (const msg of this.transport.messages()) { + if (msg.type === "system" && "subtype" in msg && msg.subtype === "init") { + const initMsg = msg as WireMessage & { + agent_id: string; + session_id: string; + conversation_id: string; + model: string; + tools: string[]; + }; + this._agentId = initMsg.agent_id; + this._sessionId = initMsg.session_id; + this._conversationId = initMsg.conversation_id; + this.initialized = true; + + return { + type: "init", + agentId: initMsg.agent_id, + sessionId: initMsg.session_id, + conversationId: initMsg.conversation_id, + model: initMsg.model, + tools: initMsg.tools, + }; + } + } + + throw new Error("Failed to initialize session - no init message received"); + } + + /** + * Send a message to the agent + */ + async send(message: string): Promise { + if (!this.initialized) { + await this.initialize(); + } + + await this.transport.write({ + type: "user", + message: { role: "user", content: message }, + }); + } + + /** + * Stream messages from the agent + */ + async *stream(): AsyncGenerator { + for await (const wireMsg of this.transport.messages()) { + // Handle CLI → SDK control requests (e.g., can_use_tool) + if (wireMsg.type === "control_request") { + const controlReq = wireMsg as ControlRequest; + if (controlReq.request.subtype === "can_use_tool") { + await this.handleCanUseTool( + controlReq.request_id, + controlReq.request as CanUseToolControlRequest + ); + continue; + } + } + + const sdkMsg = this.transformMessage(wireMsg); + if (sdkMsg) { + yield sdkMsg; + + // Stop on result message + if (sdkMsg.type === "result") { + break; + } + } + } + } + + /** + * Handle can_use_tool control request from CLI (Claude SDK compatible format) + */ + private async handleCanUseTool( + requestId: string, + req: CanUseToolControlRequest + ): Promise { + let response: CanUseToolResponse; + + if (this.options.canUseTool) { + try { + const result = await this.options.canUseTool(req.tool_name, req.input); + if (result.allow) { + response = { + behavior: "allow", + updatedInput: null, // TODO: not supported + updatedPermissions: [], // TODO: not implemented + } satisfies CanUseToolResponseAllow; + } else { + response = { + behavior: "deny", + message: result.reason ?? "Denied by canUseTool callback", + interrupt: false, // TODO: not wired up yet + } satisfies CanUseToolResponseDeny; + } + } catch (err) { + response = { + behavior: "deny", + message: err instanceof Error ? err.message : "Callback error", + interrupt: false, + }; + } + } else { + // No callback registered - deny by default + response = { + behavior: "deny", + message: "No canUseTool callback registered", + interrupt: false, + }; + } + + // Send control_response (Claude SDK compatible format) + await this.transport.write({ + type: "control_response", + response: { + subtype: "success", + request_id: requestId, + response, + }, + }); + } + + /** + * Abort the current operation (interrupt without closing the session) + */ + async abort(): Promise { + await this.transport.write({ + type: "control_request", + request_id: `interrupt-${Date.now()}`, + request: { subtype: "interrupt" }, + }); + } + + /** + * Close the session + */ + close(): void { + this.transport.close(); + } + + /** + * Get the agent ID (available after initialization) + */ + get agentId(): string | null { + return this._agentId; + } + + /** + * Get the session ID (available after initialization) + */ + get sessionId(): string | null { + return this._sessionId; + } + + /** + * Get the conversation ID (available after initialization) + */ + get conversationId(): string | null { + return this._conversationId; + } + + /** + * AsyncDisposable implementation for `await using` + */ + async [Symbol.asyncDispose](): Promise { + this.close(); + } + + /** + * Transform wire message to SDK message + */ + private transformMessage(wireMsg: WireMessage): SDKMessage | null { + // Init message + if (wireMsg.type === "system" && "subtype" in wireMsg && wireMsg.subtype === "init") { + const msg = wireMsg as WireMessage & { + agent_id: string; + session_id: string; + conversation_id: string; + model: string; + tools: string[]; + }; + return { + type: "init", + agentId: msg.agent_id, + sessionId: msg.session_id, + conversationId: msg.conversation_id, + model: msg.model, + tools: msg.tools, + }; + } + + // Handle message types (all have type: "message" with message_type field) + if (wireMsg.type === "message" && "message_type" in wireMsg) { + const msg = wireMsg as WireMessage & { + message_type: string; + uuid: string; + // assistant_message fields + content?: string; + // tool_call_message fields + tool_call?: { name: string; arguments: string; tool_call_id: string }; + tool_calls?: Array<{ name: string; arguments: string; tool_call_id: string }>; + // tool_return_message fields + tool_call_id?: string; + tool_return?: string; + status?: "success" | "error"; + // reasoning_message fields + reasoning?: string; + }; + + // Assistant message + if (msg.message_type === "assistant_message" && msg.content) { + return { + type: "assistant", + content: msg.content, + uuid: msg.uuid, + }; + } + + // Tool call message + if (msg.message_type === "tool_call_message") { + const toolCall = msg.tool_calls?.[0] || msg.tool_call; + if (toolCall) { + let toolInput: Record = {}; + try { + toolInput = JSON.parse(toolCall.arguments); + } catch { + toolInput = { raw: toolCall.arguments }; + } + return { + type: "tool_call", + toolCallId: toolCall.tool_call_id, + toolName: toolCall.name, + toolInput, + uuid: msg.uuid, + }; + } + } + + // Tool return message + if (msg.message_type === "tool_return_message" && msg.tool_call_id) { + return { + type: "tool_result", + toolCallId: msg.tool_call_id, + content: msg.tool_return || "", + isError: msg.status === "error", + uuid: msg.uuid, + }; + } + + // Reasoning message + if (msg.message_type === "reasoning_message" && msg.reasoning) { + return { + type: "reasoning", + content: msg.reasoning, + uuid: msg.uuid, + }; + } + } + + // Stream event (partial message updates) + if (wireMsg.type === "stream_event") { + const msg = wireMsg as WireMessage & { + event: { + type: string; + index?: number; + delta?: { type?: string; text?: string; reasoning?: string }; + content_block?: { type?: string; text?: string }; + }; + uuid: string; + }; + return { + type: "stream_event", + event: msg.event, + uuid: msg.uuid, + }; + } + + // Result message + if (wireMsg.type === "result") { + const msg = wireMsg as WireMessage & { + subtype: string; + result?: string; + duration_ms: number; + total_cost_usd?: number; + conversation_id: string; + }; + return { + type: "result", + success: msg.subtype === "success", + result: msg.result, + error: msg.subtype !== "success" ? msg.subtype : undefined, + durationMs: msg.duration_ms, + totalCostUsd: msg.total_cost_usd, + conversationId: msg.conversation_id, + }; + } + + // Skip other message types (system_message, user_message, etc.) + return null; + } +} diff --git a/src/transport.ts b/src/transport.ts new file mode 100644 index 0000000..503e4c0 --- /dev/null +++ b/src/transport.ts @@ -0,0 +1,342 @@ +/** + * SubprocessTransport + * + * Spawns the Letta Code CLI and communicates via stdin/stdout JSON streams. + */ + +import { spawn, type ChildProcess } from "node:child_process"; +import { createInterface, type Interface } from "node:readline"; +import type { SessionOptions, WireMessage } from "./types.js"; + +export class SubprocessTransport { + private process: ChildProcess | null = null; + private stdout: Interface | null = null; + private messageQueue: WireMessage[] = []; + private messageResolvers: Array<(msg: WireMessage) => void> = []; + private closed = false; + private agentId?: string; + + constructor( + private options: SessionOptions & { agentId?: string } = {} + ) {} + + /** + * Start the CLI subprocess + */ + async connect(): Promise { + const args = this.buildArgs(); + + // Find the CLI - use the installed letta-code package + const cliPath = await this.findCli(); + + this.process = spawn("node", [cliPath, ...args], { + cwd: this.options.cwd || process.cwd(), + stdio: ["pipe", "pipe", "pipe"], + env: { ...process.env }, + }); + + if (!this.process.stdout || !this.process.stdin) { + throw new Error("Failed to create subprocess pipes"); + } + + // Set up stdout reading + this.stdout = createInterface({ + input: this.process.stdout, + crlfDelay: Infinity, + }); + + this.stdout.on("line", (line) => { + if (!line.trim()) return; + try { + const msg = JSON.parse(line) as WireMessage; + this.handleMessage(msg); + } catch { + // Ignore non-JSON lines (stderr leakage, etc.) + } + }); + + // Handle process exit + this.process.on("close", () => { + this.closed = true; + }); + + this.process.on("error", (err) => { + console.error("CLI process error:", err); + this.closed = true; + }); + } + + /** + * Send a message to the CLI via stdin + */ + async write(data: object): Promise { + if (!this.process?.stdin || this.closed) { + throw new Error("Transport not connected"); + } + this.process.stdin.write(JSON.stringify(data) + "\n"); + } + + /** + * Read the next message from the CLI + */ + async read(): Promise { + // Return queued message if available + if (this.messageQueue.length > 0) { + return this.messageQueue.shift()!; + } + + // If closed, no more messages + if (this.closed) { + return null; + } + + // Wait for next message + return new Promise((resolve) => { + this.messageResolvers.push(resolve); + }); + } + + /** + * Async iterator for messages + */ + async *messages(): AsyncGenerator { + while (true) { + const msg = await this.read(); + if (msg === null) break; + yield msg; + } + } + + /** + * Close the transport + */ + close(): void { + if (this.process) { + this.process.stdin?.end(); + this.process.kill(); + this.process = null; + } + this.closed = true; + + // Resolve any pending readers with null + for (const resolve of this.messageResolvers) { + resolve(null as unknown as WireMessage); + } + this.messageResolvers = []; + } + + get isClosed(): boolean { + return this.closed; + } + + private handleMessage(msg: WireMessage): void { + // Track agent_id from init message + if (msg.type === "system" && "subtype" in msg && msg.subtype === "init") { + this.agentId = (msg as unknown as { agent_id: string }).agent_id; + } + + // If someone is waiting for a message, give it to them + if (this.messageResolvers.length > 0) { + const resolve = this.messageResolvers.shift()!; + resolve(msg); + } else { + // Otherwise queue it + this.messageQueue.push(msg); + } + } + + private buildArgs(): string[] { + const args: string[] = [ + "--output-format", + "stream-json", + "--input-format", + "stream-json", + ]; + + // Validate conversation + agent combinations + // (These require agentId context, so can't be in validateSessionOptions) + + // conversationId (non-default) cannot be used with agentId + if (this.options.conversationId && + this.options.conversationId !== "default" && + this.options.agentId) { + throw new Error( + "Cannot use both 'conversationId' and 'agentId'. " + + "When resuming a conversation, the agent is derived automatically." + ); + } + + // conversationId: "default" requires agentId + if (this.options.conversationId === "default" && !this.options.agentId) { + throw new Error( + "conversationId 'default' requires agentId. " + + "Use resumeSession(agentId, { defaultConversation: true }) instead." + ); + } + + // defaultConversation requires agentId + if (this.options.defaultConversation && !this.options.agentId) { + throw new Error( + "'defaultConversation' requires agentId. " + + "Use resumeSession(agentId, { defaultConversation: true })." + ); + } + + // newConversation requires agentId + if (this.options.newConversation && !this.options.agentId) { + throw new Error( + "'newConversation' requires agentId. " + + "Use resumeSession(agentId, { newConversation: true })." + ); + } + + // Conversation and agent handling + if (this.options.continue) { + // Resume last session (agent + conversation) + args.push("--continue"); + } else if (this.options.conversationId) { + // Resume specific conversation (derives agent automatically) + args.push("--conversation", this.options.conversationId); + } else if (this.options.agentId) { + // Resume existing agent + args.push("--agent", this.options.agentId); + if (this.options.newConversation) { + // Create new conversation on this agent + args.push("--new"); + } else if (this.options.defaultConversation) { + // Use agent's default conversation explicitly + args.push("--default"); + } + } else { + // Create new agent + args.push("--new-agent"); + } + + // Model + if (this.options.model) { + args.push("-m", this.options.model); + } + + // System prompt configuration + if (this.options.systemPrompt !== undefined) { + if (typeof this.options.systemPrompt === "string") { + // Raw string → --system-custom + args.push("--system-custom", this.options.systemPrompt); + } else { + // Preset object → --system (+ optional --system-append) + args.push("--system", this.options.systemPrompt.preset); + if (this.options.systemPrompt.append) { + args.push("--system-append", this.options.systemPrompt.append); + } + } + } + + // Memory blocks (only for new agents) + if (this.options.memory !== undefined && !this.options.agentId) { + if (this.options.memory.length === 0) { + // Empty array → no memory blocks (just core) + args.push("--init-blocks", ""); + } else { + // Separate preset names from custom/reference blocks + const presetNames: string[] = []; + const memoryBlocksJson: Array< + | { label: string; value: string } + | { blockId: string } + > = []; + + for (const item of this.options.memory) { + if (typeof item === "string") { + // Preset name + presetNames.push(item); + } else if ("blockId" in item) { + // Block reference - pass to --memory-blocks + memoryBlocksJson.push(item as { blockId: string }); + } else { + // CreateBlock + memoryBlocksJson.push(item as { label: string; value: string }); + } + } + + // Add preset names via --init-blocks + if (presetNames.length > 0) { + args.push("--init-blocks", presetNames.join(",")); + } + + // Add custom blocks and block references via --memory-blocks + if (memoryBlocksJson.length > 0) { + args.push("--memory-blocks", JSON.stringify(memoryBlocksJson)); + } + } + } + + // Convenience props for block values (only for new agents) + if (!this.options.agentId) { + if (this.options.persona !== undefined) { + args.push("--block-value", `persona=${this.options.persona}`); + } + if (this.options.human !== undefined) { + args.push("--block-value", `human=${this.options.human}`); + } + if (this.options.project !== undefined) { + args.push("--block-value", `project=${this.options.project}`); + } + } + + // Permission mode + if (this.options.permissionMode === "bypassPermissions") { + args.push("--yolo"); + } else if (this.options.permissionMode === "acceptEdits") { + args.push("--accept-edits"); + } + + // Allowed tools + if (this.options.allowedTools) { + args.push("--allowedTools", this.options.allowedTools.join(",")); + } + + return args; + } + + private async findCli(): Promise { + // Try multiple resolution strategies + const { existsSync } = await import("node:fs"); + const { dirname, join } = await import("node:path"); + const { fileURLToPath } = await import("node:url"); + + // Strategy 1: Check LETTA_CLI_PATH env var + if (process.env.LETTA_CLI_PATH && existsSync(process.env.LETTA_CLI_PATH)) { + return process.env.LETTA_CLI_PATH; + } + + // Strategy 2: Try to resolve from node_modules + try { + const { createRequire } = await import("node:module"); + const require = createRequire(import.meta.url); + const resolved = require.resolve("@letta-ai/letta-code/letta.js"); + if (existsSync(resolved)) { + return resolved; + } + } catch { + // Continue to next strategy + } + + // Strategy 3: Check relative to this file (for local file: deps) + const __filename = fileURLToPath(import.meta.url); + const __dirname = dirname(__filename); + const localPaths = [ + join(__dirname, "../../@letta-ai/letta-code/letta.js"), + join(__dirname, "../../../letta-code-prod/letta.js"), + join(__dirname, "../../../letta-code/letta.js"), + ]; + + for (const p of localPaths) { + if (existsSync(p)) { + return p; + } + } + + throw new Error( + "Letta Code CLI not found. Set LETTA_CLI_PATH or install @letta-ai/letta-code." + ); + } +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..19ee6ea --- /dev/null +++ b/src/types.ts @@ -0,0 +1,252 @@ +/** + * SDK Types + * + * These are the public-facing types for SDK consumers. + * Protocol types are imported from @letta-ai/letta-code/protocol. + */ + +// Re-export protocol types for internal use +export type { + WireMessage, + SystemInitMessage, + MessageWire, + ResultMessage, + ErrorMessage, + StreamEvent, + ControlRequest, + ControlResponse, + CanUseToolControlRequest, + CanUseToolResponse, + CanUseToolResponseAllow, + CanUseToolResponseDeny, + // Configuration types + SystemPromptPresetConfig, + CreateBlock, +} from "@letta-ai/letta-code/protocol"; + +// Import types for use in SessionOptions +import type { CreateBlock } from "@letta-ai/letta-code/protocol"; + +// ═══════════════════════════════════════════════════════════════ +// SYSTEM PROMPT TYPES +// ═══════════════════════════════════════════════════════════════ + +/** + * Available system prompt presets. + */ +export type SystemPromptPreset = + | "default" // Alias for letta-claude + | "letta-claude" // Full Letta Code prompt (Claude-optimized) + | "letta-codex" // Full Letta Code prompt (Codex-optimized) + | "letta-gemini" // Full Letta Code prompt (Gemini-optimized) + | "claude" // Basic Claude (no skills/memory instructions) + | "codex" // Basic Codex + | "gemini"; // Basic Gemini + +/** + * System prompt preset configuration. + */ +export interface SystemPromptPresetConfigSDK { + type: "preset"; + preset: SystemPromptPreset; + append?: string; +} + +/** + * System prompt configuration - either a raw string or preset config. + */ +export type SystemPromptConfig = string | SystemPromptPresetConfigSDK; + +// ═══════════════════════════════════════════════════════════════ +// MEMORY TYPES +// ═══════════════════════════════════════════════════════════════ + +/** + * Reference to an existing shared block by ID. + */ +export interface BlockReference { + blockId: string; +} + +/** + * Memory item - can be a preset name, custom block, or block reference. + */ +export type MemoryItem = + | string // Preset name: "project", "persona", "human" + | CreateBlock // Custom block: { label, value, description? } + | BlockReference; // Shared block reference: { blockId } + +/** + * Default memory block preset names. + */ +export type MemoryPreset = "persona" | "human" | "project"; + +// ═══════════════════════════════════════════════════════════════ +// SESSION OPTIONS +// ═══════════════════════════════════════════════════════════════ + +/** + * Result of a canUseTool callback + */ +export interface PermissionResult { + allow: boolean; + reason?: string; +} + +/** + * Callback for custom permission handling + */ +export type CanUseToolCallback = ( + toolName: string, + toolInput: Record, +) => Promise | PermissionResult; + +/** + * Options for creating a session + */ +export interface SessionOptions { + /** Model to use (e.g., "claude-sonnet-4-20250514") */ + model?: string; + + /** Resume a specific conversation by ID (derives agent automatically) */ + conversationId?: string; + + /** Create a new conversation for concurrent sessions (requires agentId) */ + newConversation?: boolean; + + /** Resume the last session (agent + conversation from previous run) */ + continue?: boolean; + + /** Use agent's default conversation (requires agentId) */ + defaultConversation?: boolean; + + /** + * System prompt configuration. + * - string: Use as the complete system prompt + * - { type: 'preset', preset, append? }: Use a preset with optional appended text + * + * Available presets: 'default', 'letta-claude', 'letta-codex', 'letta-gemini', + * 'claude', 'codex', 'gemini' + */ + systemPrompt?: SystemPromptConfig; + + /** + * Memory block configuration. Each item can be: + * - string: Preset block name ("project", "persona", "human") + * - CreateBlock: Custom block definition + * - { blockId: string }: Reference to existing shared block + * + * If not specified, defaults to ["persona", "human", "project"]. + * Core blocks (skills, loaded_skills) are always included automatically. + */ + memory?: MemoryItem[]; + + /** + * Convenience: Set persona block value directly. + * Uses default block description/limit, just overrides the value. + * Error if persona not included in memory config. + */ + persona?: string; + + /** + * Convenience: Set human block value directly. + */ + human?: string; + + /** + * Convenience: Set project block value directly. + */ + project?: string; + + /** List of allowed tool names */ + allowedTools?: string[]; + + /** Permission mode */ + permissionMode?: PermissionMode; + + /** Working directory */ + cwd?: string; + + /** Maximum conversation turns */ + maxTurns?: number; + + /** Custom permission callback - called when tool needs approval */ + canUseTool?: CanUseToolCallback; +} + +export type PermissionMode = "default" | "acceptEdits" | "bypassPermissions"; + +// ═══════════════════════════════════════════════════════════════ +// SDK MESSAGE TYPES +// ═══════════════════════════════════════════════════════════════ + +/** + * SDK message types - clean wrappers around wire types + */ +export interface SDKInitMessage { + type: "init"; + agentId: string; + sessionId: string; + conversationId: string; + model: string; + tools: string[]; +} + +export interface SDKAssistantMessage { + type: "assistant"; + content: string; + uuid: string; +} + +export interface SDKToolCallMessage { + type: "tool_call"; + toolCallId: string; + toolName: string; + toolInput: Record; + uuid: string; +} + +export interface SDKToolResultMessage { + type: "tool_result"; + toolCallId: string; + content: string; + isError: boolean; + uuid: string; +} + +export interface SDKReasoningMessage { + type: "reasoning"; + content: string; + uuid: string; +} + +export interface SDKResultMessage { + type: "result"; + success: boolean; + result?: string; + error?: string; + durationMs: number; + totalCostUsd?: number; + conversationId: string | null; +} + +export interface SDKStreamEventMessage { + type: "stream_event"; + event: { + type: string; // "content_block_start" | "content_block_delta" | "content_block_stop" + index?: number; + delta?: { type?: string; text?: string; reasoning?: string }; + content_block?: { type?: string; text?: string }; + }; + uuid: string; +} + +/** Union of all SDK message types */ +export type SDKMessage = + | SDKInitMessage + | SDKAssistantMessage + | SDKToolCallMessage + | SDKToolResultMessage + | SDKReasoningMessage + | SDKResultMessage + | SDKStreamEventMessage; diff --git a/src/validation.ts b/src/validation.ts new file mode 100644 index 0000000..98fea14 --- /dev/null +++ b/src/validation.ts @@ -0,0 +1,112 @@ +/** + * SDK Validation + * + * Validates SessionOptions before spawning the CLI. + */ + +import type { SessionOptions, MemoryItem, CreateBlock } from "./types.js"; + +/** + * Extract block labels from memory items. + */ +function getBlockLabels(memory: MemoryItem[]): string[] { + return memory + .map((item) => { + if (typeof item === "string") return item; // preset name + if ("label" in item) return (item as CreateBlock).label; // CreateBlock + return null; // blockId - no label to check + }) + .filter((label): label is string => label !== null); +} + +/** + * Validate SessionOptions before spawning CLI. + * Throws an error if validation fails. + */ +export function validateSessionOptions(options: SessionOptions): void { + // If memory is specified, validate that convenience props match included blocks + if (options.memory !== undefined) { + const blockLabels = getBlockLabels(options.memory); + + if (options.persona !== undefined && !blockLabels.includes("persona")) { + throw new Error( + "Cannot set 'persona' value - block not included in 'memory'. " + + "Either add 'persona' to memory array or remove the persona option." + ); + } + + if (options.human !== undefined && !blockLabels.includes("human")) { + throw new Error( + "Cannot set 'human' value - block not included in 'memory'. " + + "Either add 'human' to memory array or remove the human option." + ); + } + + if (options.project !== undefined && !blockLabels.includes("project")) { + throw new Error( + "Cannot set 'project' value - block not included in 'memory'. " + + "Either add 'project' to memory array or remove the project option." + ); + } + } + + // Validate systemPrompt preset if provided + if ( + options.systemPrompt !== undefined && + typeof options.systemPrompt === "object" + ) { + const validPresets = [ + "default", + "letta-claude", + "letta-codex", + "letta-gemini", + "claude", + "codex", + "gemini", + ]; + if (!validPresets.includes(options.systemPrompt.preset)) { + throw new Error( + `Invalid system prompt preset '${options.systemPrompt.preset}'. ` + + `Valid presets: ${validPresets.join(", ")}` + ); + } + } + + // Validate conversation options + if (options.conversationId && options.newConversation) { + throw new Error( + "Cannot use both 'conversationId' and 'newConversation'. " + + "Use conversationId to resume a specific conversation, or newConversation to create a new one." + ); + } + + if (options.continue && options.conversationId) { + throw new Error( + "Cannot use both 'continue' and 'conversationId'. " + + "Use continue to resume the last session, or conversationId to resume a specific conversation." + ); + } + + if (options.continue && options.newConversation) { + throw new Error( + "Cannot use both 'continue' and 'newConversation'. " + + "Use continue to resume the last session, or newConversation to create a new one." + ); + } + + if (options.defaultConversation && options.conversationId) { + throw new Error( + "Cannot use both 'defaultConversation' and 'conversationId'. " + + "Use defaultConversation with agentId, or conversationId alone." + ); + } + + if (options.defaultConversation && options.newConversation) { + throw new Error( + "Cannot use both 'defaultConversation' and 'newConversation'." + ); + } + + // Note: Validations that require agentId context happen in transport.ts buildArgs() + // because agentId is passed separately to resumeSession(), not in SessionOptions +} diff --git a/tsconfig.build.json b/tsconfig.build.json new file mode 100644 index 0000000..f7130ad --- /dev/null +++ b/tsconfig.build.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "emitDeclarationOnly": true, + "declaration": true, + "declarationMap": true, + "outDir": "./dist" + }, + "include": ["src/**/*"], + "exclude": ["**/*.test.ts"] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..a09e1bd --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "lib": ["ESNext"], + "target": "ESNext", + "module": "Preserve", + "moduleDetection": "force", + "allowJs": true, + + "moduleResolution": "bundler", + "resolveJsonModule": true, + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + + "noUnusedLocals": false, + "noUnusedParameters": false + }, + "include": ["src/**/*", "build.ts"] +}