Initial release of Letta Code SDK
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)
This commit is contained in:
63
.github/workflows/ci.yml
vendored
Normal file
63
.github/workflows/ci.yml
vendored
Normal file
@@ -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
|
||||
149
.github/workflows/release.yml
vendored
Normal file
149
.github/workflows/release.yml
vendored
Normal file
@@ -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 }}
|
||||
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
node_modules/
|
||||
dist/
|
||||
bun.lockb
|
||||
bun.lock
|
||||
*.log
|
||||
.DS_Store
|
||||
.letta/
|
||||
190
LICENSE
Normal file
190
LICENSE
Normal file
@@ -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.
|
||||
328
README.md
Normal file
328
README.md
Normal file
@@ -0,0 +1,328 @@
|
||||
# Letta Code SDK
|
||||
|
||||
[](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<string | CreateBlock | { blockId: string }>;
|
||||
|
||||
// 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
|
||||
40
build.ts
Normal file
40
build.ts
Normal file
@@ -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/`);
|
||||
822
examples/v2-examples.ts
Normal file
822
examples/v2-examples.ts
Normal file
@@ -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);
|
||||
40
package.json
Normal file
40
package.json
Normal file
@@ -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"
|
||||
}
|
||||
159
src/index.ts
Normal file
159
src/index.ts
Normal file
@@ -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<SDKResultMessage> {
|
||||
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();
|
||||
}
|
||||
}
|
||||
358
src/session.ts
Normal file
358
src/session.ts
Normal file
@@ -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<SDKInitMessage> {
|
||||
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<void> {
|
||||
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<SDKMessage> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<string, unknown> = {};
|
||||
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;
|
||||
}
|
||||
}
|
||||
342
src/transport.ts
Normal file
342
src/transport.ts
Normal file
@@ -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<void> {
|
||||
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<void> {
|
||||
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<WireMessage | null> {
|
||||
// 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<WireMessage> {
|
||||
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<string> {
|
||||
// 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."
|
||||
);
|
||||
}
|
||||
}
|
||||
252
src/types.ts
Normal file
252
src/types.ts
Normal file
@@ -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<string, unknown>,
|
||||
) => Promise<PermissionResult> | 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<string, unknown>;
|
||||
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;
|
||||
112
src/validation.ts
Normal file
112
src/validation.ts
Normal file
@@ -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
|
||||
}
|
||||
12
tsconfig.build.json
Normal file
12
tsconfig.build.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": false,
|
||||
"emitDeclarationOnly": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"outDir": "./dist"
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["**/*.test.ts"]
|
||||
}
|
||||
25
tsconfig.json
Normal file
25
tsconfig.json
Normal file
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user