Merge pull request #30 from letta-ai/feat/sdk-only-breaking-change

feat!: SDK-only transport — remove legacy API path
This commit is contained in:
Cameron
2026-03-13 18:23:06 -07:00
committed by GitHub
13 changed files with 346 additions and 1099 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "claude-subconscious",
"version": "1.5.1",
"version": "2.0.0",
"description": "A subconscious for Claude Code. A Letta agent watches your sessions, accumulates context, and whispers guidance back.",
"author": {
"name": "Letta",

View File

@@ -111,7 +111,6 @@ export LETTA_BASE_URL="http://localhost:8283" # For self-hosted Letta
export LETTA_MODEL="anthropic/claude-sonnet-4-5" # Model override
export LETTA_CONTEXT_WINDOW="1048576" # Context window size (e.g. 1M tokens)
export LETTA_HOME="$HOME" # Consolidate .letta state to ~/.letta/
export LETTA_CHECKPOINT_MODE="blocking" # Or "async", "off"
export LETTA_SDK_TOOLS="read-only" # Or "full", "off"
```
@@ -121,8 +120,7 @@ export LETTA_SDK_TOOLS="read-only" # Or "full", "off"
- `LETTA_MODEL` - Override the agent's model. Optional - the plugin auto-detects and selects from available models. See [Model Configuration](#model-configuration) below.
- `LETTA_CONTEXT_WINDOW` - Override the agent's context window size (in tokens). Useful when `LETTA_MODEL` is set to a model with a large context window that differs from the server default. Example: `1048576` for 1M tokens.
- `LETTA_HOME` - Base directory for plugin state files. Creates `{LETTA_HOME}/.letta/claude/` for session data and conversation mappings. Defaults to current working directory. Set to `$HOME` to consolidate all state in one location.
- `LETTA_CHECKPOINT_MODE` - Controls checkpoint behavior at natural pause points (`AskUserQuestion`, `ExitPlanMode`). See [Checkpoint Hooks](#checkpoint-hooks).
- `LETTA_SDK_TOOLS` - Controls client-side tool access for the Subconscious agent. See [SDK Tools](#sdk-tools).
- `LETTA_SDK_TOOLS` - Controls client-side tool access for the Subconscious agent. `read-only` (default), `full`, or `off`. See [SDK Tools](#sdk-tools).
### Modes
@@ -255,9 +253,8 @@ The plugin uses four Claude Code hooks:
|------|--------|---------|---------|
| `SessionStart` | `session_start.ts` | 5s | Notifies agent, cleans up legacy CLAUDE.md |
| `UserPromptSubmit` | `sync_letta_memory.ts` | 10s | Injects memory + messages via stdout |
| `PreToolUse` (checkpoint) | `plan_checkpoint.ts` | 10s | Sends transcript at `AskUserQuestion`/`ExitPlanMode` |
| `PreToolUse` (general) | `pretool_sync.ts` | 5s | Mid-workflow updates via `additionalContext` |
| `Stop` | `send_messages_to_letta.ts` | 15s | Spawns background worker to send transcript |
| `PreToolUse` | `pretool_sync.ts` | 5s | Mid-workflow updates via `additionalContext` |
| `Stop` | `send_messages_to_letta.ts` | 120s | Spawns SDK worker to send transcript (async) |
### SessionStart
@@ -273,7 +270,6 @@ Before each prompt is processed:
- Fetches agent's current memory blocks and messages
- In `full` mode: injects all blocks on first prompt, diffs on subsequent prompts
- In `whisper` mode: injects only messages from Sub
- Sends user prompt to Letta early (gives the agent a head start)
### PreToolUse
@@ -282,29 +278,6 @@ Before each tool use:
- If updates found, injects them via `additionalContext`
- Silent no-op if nothing changed
### Checkpoint Hooks
At certain "natural pause points" — when Claude asks a question (`AskUserQuestion`) or finishes planning (`ExitPlanMode`) — the plugin sends the current transcript to Letta so your Subconscious can provide guidance before Claude proceeds.
**Why this matters:** Normally, Letta only sees transcripts when Claude stops responding (via the Stop hook). Checkpoint hooks let your Subconscious intervene at decision points:
- Before the user answers a question Claude asked
- Before implementation begins after a plan is approved
**Configuration via `LETTA_CHECKPOINT_MODE`:**
| Mode | Behavior |
|------|----------|
| `blocking` (default) | Wait for Letta response (~2-5s), inject as `additionalContext` before tool executes |
| `async` | Fire-and-forget; guidance arrives on next `UserPromptSubmit` |
| `off` | Disable checkpoint hooks; only Stop hook sends transcripts |
In blocking mode, Letta's response is injected as:
```xml
<letta_message checkpoint="AskUserQuestion">
Consider asking about X before proceeding...
</letta_message>
```
### SDK Tools
By default, the Subconscious agent now gets **client-side tool access** via the [Letta Code SDK](https://docs.letta.com/letta-code/sdk/). Instead of being limited to memory operations, Sub can read your files, search the web, and explore your codebase while processing transcripts.
@@ -315,9 +288,9 @@ By default, the Subconscious agent now gets **client-side tool access** via the
|------|----------------|----------|
| `read-only` (default) | `Read`, `Grep`, `Glob`, `web_search`, `fetch_webpage` | Safe background research and file reading |
| `full` | All tools (Bash, Edit, Write, etc.) | Full autonomy — Sub can make changes |
| `off` | None (memory-only) | Legacy behavior, raw API transport |
| `off` | None (memory-only) | Listen-only — Sub processes transcripts but has no client-side tools |
> **Note:** Requires `@letta-ai/letta-code-sdk` (installed as a dependency). Set `LETTA_SDK_TOOLS=off` to use the legacy raw API path without the SDK.
> **Note:** Requires `@letta-ai/letta-code-sdk` (installed as a dependency).
### Stop
@@ -330,9 +303,9 @@ Uses an **async hook** pattern — runs in the background without blocking Claud
- Spawns detached background worker
- Exits immediately
2. Background worker runs independently:
- **SDK mode** (`send_worker_sdk.ts`): Opens a Letta Code SDK session, giving Sub client-side tools
- **Legacy mode** (`send_worker.ts`): Sends via raw API (memory-only)
2. Background worker (`send_worker_sdk.ts`) runs independently:
- Opens a Letta Code SDK session, giving Sub client-side tools
- Sub processes the transcript and can use Read/Grep/Glob to explore the codebase
- Updates state on success
- Cleans up temp file
@@ -353,9 +326,8 @@ Persisted in your project directory (this is **conversation bookkeeping**, not a
Log files for debugging:
- `session_start.log` - Session initialization
- `sync_letta_memory.log` - Memory sync operations
- `plan_checkpoint.log` - Checkpoint hooks (AskUserQuestion/ExitPlanMode)
- `send_messages.log` - Main Stop hook
- `send_worker.log` - Background worker
- `send_worker_sdk.log` - SDK background worker
## What Your Agent Receives
@@ -447,7 +419,7 @@ tail -f /tmp/letta-claude-sync-$(id -u)/*.log
# Or specific logs
tail -f /tmp/letta-claude-sync-$(id -u)/send_messages.log
tail -f /tmp/letta-claude-sync-$(id -u)/send_worker.log
tail -f /tmp/letta-claude-sync-$(id -u)/send_worker_sdk.log
```
## API Notes

File diff suppressed because one or more lines are too long

View File

@@ -18,16 +18,6 @@
}
],
"PreToolUse": [
{
"matcher": "AskUserQuestion|ExitPlanMode",
"hooks": [
{
"type": "command",
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/silent-npx.cjs\" tsx \"${CLAUDE_PLUGIN_ROOT}/scripts/plan_checkpoint.ts\"",
"timeout": 10
}
]
},
{
"matcher": "*",
"hooks": [

View File

@@ -1,6 +1,6 @@
{
"name": "claude-subconscious",
"version": "1.5.1",
"version": "2.0.0",
"description": "A subconscious for Claude Code. A Letta agent watches your sessions, accumulates context, and whispers guidance back.",
"author": "Letta <hello@letta.com> (https://letta.com)",
"license": "MIT",

View File

@@ -75,7 +75,7 @@ export const SDK_TOOLS_BLOCKED = ['AskUserQuestion', 'EnterPlanMode', 'ExitPlanM
* Get the SDK tools mode from LETTA_SDK_TOOLS env var.
* - read-only (default): Sub can read files and search the web
* - full: Sub has full tool access (use with caution)
* - off: Legacy mode, no SDK — raw API only (memory-only Sub)
* - off: No client-side tools (listen-only, memory operations only)
*/
export function getSdkToolsMode(): SdkToolsMode {
const mode = process.env.LETTA_SDK_TOOLS?.toLowerCase();
@@ -314,41 +314,6 @@ export function lookupConversation(cwd: string, sessionId: string): string | nul
}
}
/**
* Send a message to a Letta conversation (fire-and-forget style)
* Returns the response for the caller to handle
*/
export async function sendMessageToConversation(
apiKey: string,
conversationId: string,
role: string,
text: string,
log: LogFn = noopLog
): Promise<Response> {
const url = `${LETTA_API_BASE}/conversations/${conversationId}/messages`;
log(`Sending ${role} message to conversation ${conversationId} (${text.length} chars)`);
const response = await fetch(url, {
method: 'POST',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
messages: [
{
role: role,
content: text,
}
],
}),
});
log(`Response status: ${response.status}`);
return response;
}
// ============================================
// Agent and Memory Block Types
// ============================================

View File

@@ -1,379 +0,0 @@
#!/usr/bin/env npx tsx
/**
* Plan Checkpoint Script
*
* Triggered by PreToolUse hook on AskUserQuestion and ExitPlanMode tools.
* Sends partial transcript to Letta at these natural pause points so the
* Subconscious agent can provide guidance before Claude proceeds.
*
* Environment Variables:
* LETTA_API_KEY - API key for Letta authentication
* LETTA_CHECKPOINT_MODE - Mode: 'blocking' (default), 'async', or 'off'
*
* Hook Input (via stdin):
* - session_id: Current session ID
* - transcript_path: Path to conversation JSONL file
* - tool_name: The tool being called (AskUserQuestion or ExitPlanMode)
* - tool_input: The tool's input parameters
* - cwd: Current working directory
*
* Exit Codes:
* 0 - Success
* 1 - Error (non-blocking)
*
* Log file: $TMPDIR/letta-claude-sync-$UID/plan_checkpoint.log
*/
import * as fs from 'fs';
import * as path from 'path';
import { fileURLToPath } from 'url';
import { getAgentId } from './agent_config.js';
import {
LETTA_API_BASE,
loadSyncState,
getOrCreateConversation,
saveSyncState,
spawnSilentWorker,
getSyncStateFile,
LogFn,
getTempStateDir,
} from './conversation_utils.js';
import {
readTranscript,
formatMessagesForLetta,
formatAsXmlTranscript,
} from './transcript_utils.js';
// ESM-compatible __dirname
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Configuration
const TEMP_STATE_DIR = getTempStateDir();
const LOG_FILE = path.join(TEMP_STATE_DIR, 'plan_checkpoint.log');
type CheckpointMode = 'blocking' | 'async' | 'off';
interface HookInput {
session_id: string;
transcript_path: string;
tool_name: string;
tool_input: any;
cwd: string;
}
interface HookOutput {
hookSpecificOutput?: {
hookEventName: string;
additionalContext?: string;
};
}
/**
* Ensure temp log directory exists
*/
function ensureLogDir(): void {
if (!fs.existsSync(TEMP_STATE_DIR)) {
fs.mkdirSync(TEMP_STATE_DIR, { recursive: true });
}
}
/**
* Log message to file
*/
function log(message: string): void {
ensureLogDir();
const timestamp = new Date().toISOString();
const logLine = `[${timestamp}] ${message}\n`;
fs.appendFileSync(LOG_FILE, logLine);
}
/**
* Get checkpoint mode from environment
*/
function getCheckpointMode(): CheckpointMode {
const mode = process.env.LETTA_CHECKPOINT_MODE?.toLowerCase();
if (mode === 'async' || mode === 'off') return mode;
return 'blocking';
}
/**
* Read hook input from stdin
*/
async function readHookInput(): Promise<HookInput> {
return new Promise((resolve, reject) => {
let data = '';
process.stdin.setEncoding('utf8');
process.stdin.on('readable', () => {
let chunk;
while ((chunk = process.stdin.read()) !== null) {
data += chunk;
}
});
process.stdin.on('end', () => {
try {
resolve(JSON.parse(data));
} catch (e) {
reject(new Error(`Failed to parse hook input: ${e}`));
}
});
process.stdin.on('error', reject);
});
}
/**
* Format tool context for the checkpoint message
*/
function formatToolContext(toolName: string, toolInput: any): string {
if (toolName === 'AskUserQuestion') {
const questions = toolInput?.questions;
if (Array.isArray(questions) && questions.length > 0) {
const questionTexts = questions.map((q: any) => {
let text = q.question || '';
if (q.options && Array.isArray(q.options)) {
const optionLabels = q.options.map((o: any) => o.label).join(', ');
text += ` [Options: ${optionLabels}]`;
}
return text;
}).join('\n');
return `<current_tool name="AskUserQuestion">
Claude Code is about to ask the user:
${questionTexts}
</current_tool>`;
}
} else if (toolName === 'ExitPlanMode') {
return `<current_tool name="ExitPlanMode">
Claude Code is finishing plan mode and requesting user approval to proceed with implementation.
</current_tool>`;
}
return '';
}
/**
* Send message to Letta and wait for response (blocking mode)
*/
async function sendAndWaitForResponse(
apiKey: string,
conversationId: string,
message: string,
log: LogFn
): Promise<string | null> {
const url = `${LETTA_API_BASE}/conversations/${conversationId}/messages`;
log(`Sending blocking message to conversation ${conversationId}`);
const response = await fetch(url, {
method: 'POST',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
messages: [{ role: 'user', content: message }],
}),
});
if (response.status === 409) {
log(`Conversation busy (409) - skipping checkpoint`);
return null;
}
if (!response.ok) {
const errorText = await response.text();
log(`Error response: ${errorText}`);
throw new Error(`Letta API error (${response.status}): ${errorText}`);
}
// Read the full streaming response and extract assistant message
const reader = response.body?.getReader();
if (!reader) {
log(`No response body`);
return null;
}
let fullResponse = '';
const decoder = new TextDecoder();
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
fullResponse += decoder.decode(value, { stream: true });
}
} finally {
reader.releaseLock();
}
log(`Received response (${fullResponse.length} chars)`);
// Parse SSE events to extract assistant message
// Format: data: {"message_type": "assistant_message", "content": "..."}
const lines = fullResponse.split('\n');
let assistantContent = '';
for (const line of lines) {
if (line.startsWith('data: ')) {
try {
const data = JSON.parse(line.substring(6));
if (data.message_type === 'assistant_message' && data.content) {
assistantContent += data.content;
}
} catch (e) {
// Skip non-JSON lines
}
}
}
if (assistantContent) {
log(`Extracted assistant message (${assistantContent.length} chars)`);
return assistantContent;
}
log(`No assistant message found in response`);
return null;
}
/**
* Main function
*/
async function main(): Promise<void> {
log('='.repeat(60));
log('plan_checkpoint.ts started');
const mode = getCheckpointMode();
log(`Checkpoint mode: ${mode}`);
if (mode === 'off') {
log('Checkpoint mode is off, exiting');
process.exit(0);
}
const apiKey = process.env.LETTA_API_KEY;
if (!apiKey) {
log('ERROR: LETTA_API_KEY not set');
process.exit(0); // Exit silently - don't block Claude
}
try {
// Get agent ID
const agentId = await getAgentId(apiKey, log);
log(`Using agent: ${agentId}`);
// Read hook input
log('Reading hook input from stdin...');
const hookInput = await readHookInput();
log(`Hook input received:`);
log(` session_id: ${hookInput.session_id}`);
log(` transcript_path: ${hookInput.transcript_path}`);
log(` tool_name: ${hookInput.tool_name}`);
log(` cwd: ${hookInput.cwd}`);
// Read transcript
log(`Reading transcript from: ${hookInput.transcript_path}`);
const messages = await readTranscript(hookInput.transcript_path, log);
log(`Found ${messages.length} messages in transcript`);
if (messages.length === 0) {
log('No messages found, exiting');
process.exit(0);
}
// Load sync state (don't update lastProcessedIndex - let Stop hook do that)
const state = loadSyncState(hookInput.cwd, hookInput.session_id, log);
// Format new messages since last sync
const newMessages = formatMessagesForLetta(messages, state.lastProcessedIndex, log);
// Get or create conversation
const conversationId = await getOrCreateConversation(
apiKey,
agentId,
hookInput.session_id,
hookInput.cwd,
state,
log
);
log(`Using conversation: ${conversationId}`);
// Save state with conversation ID
saveSyncState(hookInput.cwd, state, log);
// Build checkpoint message
const toolContext = formatToolContext(hookInput.tool_name, hookInput.tool_input);
const transcriptXml = newMessages.length > 0 ? formatAsXmlTranscript(newMessages) : '';
const checkpointMessage = `<claude_code_checkpoint>
<session_id>${hookInput.session_id}</session_id>
<checkpoint_type>${hookInput.tool_name}</checkpoint_type>
${toolContext}
${transcriptXml ? `<recent_transcript>\n${transcriptXml}\n</recent_transcript>` : ''}
<instructions>
Claude Code is at a checkpoint (${hookInput.tool_name}). This is a good moment to provide guidance if you have any.
Your response will be injected as additionalContext before Claude proceeds. Keep it brief and actionable.
If you have no guidance, you can respond with just "No guidance needed" or similar.
</instructions>
</claude_code_checkpoint>`;
log(`Built checkpoint message (${checkpointMessage.length} chars)`);
if (mode === 'blocking') {
// Wait for Letta response and inject as additionalContext
const assistantResponse = await sendAndWaitForResponse(
apiKey,
conversationId,
checkpointMessage,
log
);
if (assistantResponse) {
const output: HookOutput = {
hookSpecificOutput: {
hookEventName: 'PreToolUse',
additionalContext: `<letta_message checkpoint="${hookInput.tool_name}">\n${assistantResponse}\n</letta_message>`,
},
};
console.log(JSON.stringify(output));
log('Wrote additionalContext to stdout');
} else {
log('No response to inject');
}
} else {
// Async mode: spawn worker and don't wait
const payloadFile = path.join(TEMP_STATE_DIR, `checkpoint-${hookInput.session_id}-${Date.now()}.json`);
const payload = {
apiKey,
conversationId,
sessionId: hookInput.session_id,
message: checkpointMessage,
stateFile: getSyncStateFile(hookInput.cwd, hookInput.session_id),
// Don't update lastProcessedIndex for checkpoints
newLastProcessedIndex: null,
};
fs.writeFileSync(payloadFile, JSON.stringify(payload), 'utf-8');
log(`Wrote payload to ${payloadFile}`);
const workerScript = path.join(__dirname, 'send_worker.ts');
const child = spawnSilentWorker(workerScript, payloadFile, hookInput.cwd);
log(`Spawned background worker (PID: ${child.pid})`);
}
log('Checkpoint completed');
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
log(`ERROR: ${errorMessage}`);
if (error instanceof Error && error.stack) {
log(`Stack trace: ${error.stack}`);
}
// Don't exit with error code - don't block Claude
process.exit(0);
}
}
// Run main function
main();

View File

@@ -26,14 +26,11 @@ import * as path from 'path';
import { fileURLToPath } from 'url';
import { getAgentId } from './agent_config.js';
import {
LETTA_API_BASE,
loadSyncState,
saveSyncState,
getOrCreateConversation,
getSyncStateFile,
spawnSilentWorker,
SyncState,
LogFn,
getMode,
getTempStateDir,
getSdkToolsMode,
@@ -41,7 +38,6 @@ import {
import {
readTranscript,
formatMessagesForLetta,
TranscriptMessage,
} from './transcript_utils.js';
// ESM-compatible __dirname
@@ -104,119 +100,6 @@ async function readHookInput(): Promise<HookInput> {
}
interface SendResult {
skipped: boolean;
}
/**
* Send a message to a Letta conversation
* Note: The conversations API streams responses, so we consume minimally
* Returns { skipped: true } if conversation is busy (409), otherwise { skipped: false }
*/
async function sendMessageToConversation(
apiKey: string,
conversationId: string,
role: string,
text: string
): Promise<SendResult> {
const url = `${LETTA_API_BASE}/conversations/${conversationId}/messages`;
log(`Sending ${role} message to conversation ${conversationId} (${text.length} chars)`);
const response = await fetch(url, {
method: 'POST',
headers: {
'Authorization': `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
messages: [
{
role: role,
content: text,
}
],
}),
});
log(` Response status: ${response.status}`);
// Handle 409 Conflict gracefully - conversation is busy, skip and retry on next Stop
if (response.status === 409) {
log(` Conversation busy (409) - skipping, will sync on next Stop`);
return { skipped: true };
}
if (!response.ok) {
const errorText = await response.text();
log(` Error response: ${errorText}`);
throw new Error(`Letta API error (${response.status}): ${errorText}`);
}
// Consume the stream minimally - just read first chunk to confirm it started
// The agent will continue processing in the background
const reader = response.body?.getReader();
if (reader) {
try {
const { value } = await reader.read();
if (value) {
const chunk = new TextDecoder().decode(value);
log(` Stream started, first chunk: ${chunk.substring(0, 100)}...`);
}
} finally {
reader.cancel(); // Release the stream
}
}
log(` Message sent to conversation successfully`);
return { skipped: false };
}
/**
* Send batch of messages to Letta conversation (as a combined system message for context)
* Returns { skipped: true } if conversation was busy, { skipped: false } otherwise
*/
async function sendBatchToConversation(
apiKey: string,
conversationId: string,
sessionId: string,
messages: Array<{role: string, text: string}>
): Promise<SendResult> {
if (messages.length === 0) {
log(`No messages to send`);
return { skipped: false };
}
// Format as XML-structured transcript
const transcriptEntries = messages.map(m => {
const role = m.role === 'user' ? 'user' : m.role === 'assistant' ? 'claude_code' : 'system';
// Escape XML special chars in text
const escaped = m.text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
return `<message role="${role}">\n${escaped}\n</message>`;
}).join('\n');
const userMessage = `<claude_code_session_update>
<session_id>${sessionId}</session_id>
<transcript>
${transcriptEntries}
</transcript>
<instructions>
You may provide commentary or guidance for Claude Code. Your response will be added to Claude's context window on the next prompt. Use this to:
- Offer observations about the user's work
- Provide reminders or context from your memory
- Suggest approaches or flag potential issues
- Send async messages/guidance to Claude Code
Write your response as if speaking directly to Claude Code.
</instructions>
</claude_code_session_update>`;
log(`Sending batch of ${messages.length} messages to conversation ${conversationId}`);
return await sendMessageToConversation(apiKey, conversationId, 'user', userMessage);
}
/**
* Main function
*/
@@ -323,47 +206,29 @@ Write your response as if speaking directly to Claude Code.
</instructions>
</claude_code_session_update>`;
// Decide transport: SDK (with client-side tools) or legacy (raw API)
// Send via Letta Code SDK (Sub gets client-side tools)
const sdkToolsMode = getSdkToolsMode();
log(`SDK tools mode: ${sdkToolsMode}`);
const payloadFile = path.join(TEMP_STATE_DIR, `payload-${hookInput.session_id}-${Date.now()}.json`);
const stateFile = getSyncStateFile(hookInput.cwd, hookInput.session_id);
if (sdkToolsMode !== 'off') {
// SDK mode: send via Letta Code SDK (Sub gets client-side tools)
const sdkPayload = {
agentId,
sessionId: hookInput.session_id,
message: userMessage,
stateFile,
newLastProcessedIndex: messages.length - 1,
cwd: hookInput.cwd,
sdkToolsMode,
};
fs.writeFileSync(payloadFile, JSON.stringify(sdkPayload), 'utf-8');
log(`Wrote SDK payload to ${payloadFile}`);
const sdkPayload = {
agentId,
conversationId,
sessionId: hookInput.session_id,
message: userMessage,
stateFile,
newLastProcessedIndex: messages.length - 1,
cwd: hookInput.cwd,
sdkToolsMode,
};
fs.writeFileSync(payloadFile, JSON.stringify(sdkPayload), 'utf-8');
log(`Wrote SDK payload to ${payloadFile}`);
const workerScript = path.join(__dirname, 'send_worker_sdk.ts');
const child = spawnSilentWorker(workerScript, payloadFile, hookInput.cwd);
log(`Spawned SDK worker (PID: ${child.pid})`);
} else {
// Legacy mode: send via raw API (memory-only Sub)
const legacyPayload = {
apiKey,
conversationId,
sessionId: hookInput.session_id,
message: userMessage,
stateFile,
newLastProcessedIndex: messages.length - 1,
};
fs.writeFileSync(payloadFile, JSON.stringify(legacyPayload), 'utf-8');
log(`Wrote legacy payload to ${payloadFile}`);
const workerScript = path.join(__dirname, 'send_worker.ts');
const child = spawnSilentWorker(workerScript, payloadFile, hookInput.cwd);
log(`Spawned legacy worker (PID: ${child.pid})`);
}
const workerScript = path.join(__dirname, 'send_worker_sdk.ts');
const child = spawnSilentWorker(workerScript, payloadFile, hookInput.cwd);
log(`Spawned SDK worker (PID: ${child.pid})`);
log('Hook completed (worker running in background)');

View File

@@ -1,126 +0,0 @@
#!/usr/bin/env npx tsx
/**
* Background worker that sends messages to Letta
* Spawned by send_messages_to_letta.ts as a detached process
*
* Usage: npx tsx send_worker.ts <payload_file>
*/
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
const LETTA_BASE_URL = process.env.LETTA_BASE_URL || 'https://api.letta.com';
const LETTA_API_BASE = `${LETTA_BASE_URL}/v1`;
const uid = typeof process.getuid === 'function' ? process.getuid() : process.pid;
const LOG_FILE = path.join(os.tmpdir(), `letta-claude-sync-${uid}`, 'send_worker.log');
interface Payload {
apiKey: string;
conversationId: string;
sessionId: string;
message: string;
stateFile: string;
newLastProcessedIndex: number;
}
function log(message: string): void {
const dir = path.dirname(LOG_FILE);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
const timestamp = new Date().toISOString();
fs.appendFileSync(LOG_FILE, `[${timestamp}] ${message}\n`);
}
async function sendToLetta(payload: Payload): Promise<boolean> {
const url = `${LETTA_API_BASE}/conversations/${payload.conversationId}/messages`;
log(`Sending to conversation ${payload.conversationId} (${payload.message.length} chars)`);
const response = await fetch(url, {
method: 'POST',
headers: {
'Authorization': `Bearer ${payload.apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
messages: [{ role: 'user', content: payload.message }],
}),
});
log(`Response status: ${response.status}`);
if (response.status === 409) {
log('Conversation busy (409) - will retry on next Stop');
return false;
}
if (!response.ok) {
const errorText = await response.text();
log(`Error: ${errorText}`);
throw new Error(`Letta API error (${response.status}): ${errorText}`);
}
// Consume the stream to completion
const reader = response.body?.getReader();
if (reader) {
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = new TextDecoder().decode(value);
log(`Chunk: ${chunk.substring(0, 100)}...`);
}
} finally {
reader.cancel();
}
}
log('Message sent successfully');
return true;
}
async function main(): Promise<void> {
const payloadFile = process.argv[2];
if (!payloadFile) {
log('ERROR: No payload file specified');
process.exit(1);
}
log('='.repeat(60));
log(`Worker started with payload: ${payloadFile}`);
try {
if (!fs.existsSync(payloadFile)) {
log(`ERROR: Payload file not found: ${payloadFile}`);
process.exit(1);
}
const payload: Payload = JSON.parse(fs.readFileSync(payloadFile, 'utf-8'));
log(`Loaded payload for session ${payload.sessionId}`);
const success = await sendToLetta(payload);
if (success) {
// Update state file
const state = JSON.parse(fs.readFileSync(payload.stateFile, 'utf-8'));
state.lastProcessedIndex = payload.newLastProcessedIndex;
fs.writeFileSync(payload.stateFile, JSON.stringify(state, null, 2));
log(`Updated state: lastProcessedIndex=${payload.newLastProcessedIndex}`);
}
// Clean up payload file
fs.unlinkSync(payloadFile);
log('Cleaned up payload file');
log('Worker completed successfully');
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
log(`ERROR: ${errorMessage}`);
process.exit(1);
}
}
main();

View File

@@ -19,6 +19,7 @@ const LOG_FILE = path.join(TEMP_STATE_DIR, 'send_worker_sdk.log');
interface SdkPayload {
agentId: string;
conversationId: string;
sessionId: string;
message: string;
stateFile: string;
@@ -55,16 +56,21 @@ async function sendViaSdk(payload: SdkPayload): Promise<boolean> {
sleeptime: { trigger: 'off' }, // don't recurse sleeptime
};
if (payload.sdkToolsMode === 'read-only') {
if (payload.sdkToolsMode === 'off') {
// Listen-only: block all client-side tools, Sub can only use memory operations
sessionOptions.disallowedTools = [...blockedTools, ...readOnlyTools, 'Bash', 'Edit', 'Write', 'Task', 'Glob', 'Grep', 'Read'];
} else if (payload.sdkToolsMode === 'read-only') {
sessionOptions.allowedTools = readOnlyTools;
}
// 'full' mode: no allowedTools restriction (all tools available)
log(`Creating SDK session for agent ${payload.agentId} (mode: ${payload.sdkToolsMode})`);
const toolsLabel = payload.sdkToolsMode === 'off' ? 'none' : payload.sdkToolsMode === 'read-only' ? readOnlyTools.join(', ') : 'all';
log(`Creating SDK session for conversation ${payload.conversationId} (mode: ${payload.sdkToolsMode})`);
log(` agent: ${payload.agentId}`);
log(` cwd: ${payload.cwd}`);
log(` allowedTools: ${payload.sdkToolsMode === 'read-only' ? readOnlyTools.join(', ') : 'all'}`);
log(` allowedTools: ${toolsLabel}`);
const session = resumeSession(payload.agentId, sessionOptions);
const session = resumeSession(payload.conversationId, sessionOptions);
try {
log(`Sending message (${payload.message.length} chars)...`);
@@ -79,6 +85,10 @@ async function sendViaSdk(payload: SdkPayload): Promise<boolean> {
if (msg.type === 'assistant' && msg.content) {
assistantResponse += msg.content;
log(` Assistant chunk: ${msg.content.substring(0, 100)}...`);
} else if (msg.type === 'tool_call') {
log(` Tool call: ${(msg as any).toolName}`);
} else if (msg.type === 'error') {
log(` Error: ${(msg as any).message}`);
}
}

View File

@@ -285,11 +285,11 @@ async function main(): Promise<void> {
writeTty('\n');
// Settings
const checkpointMode = process.env.LETTA_CHECKPOINT_MODE || 'blocking';
const sdkTools = process.env.LETTA_SDK_TOOLS || 'read-only';
const baseUrl = process.env.LETTA_BASE_URL || 'https://api.letta.com';
writeTty(` Model: ${modelHandle}\n`);
writeTty(` Mode: ${mode}\n`);
writeTty(` Checkpoint: ${checkpointMode}\n`);
writeTty(` SDK Tools: ${sdkTools}\n`);
if (process.env.LETTA_BASE_URL) {
writeTty(` Server: ${baseUrl}\n`);
}

View File

@@ -20,15 +20,12 @@
import * as fs from 'fs';
import * as path from 'path';
import * as readline from 'readline';
import { fileURLToPath } from 'url';
import { getAgentId } from './agent_config.js';
import {
loadSyncState,
saveSyncState,
getOrCreateConversation,
getSyncStateFile,
lookupConversation,
spawnSilentWorker,
SyncState,
Agent,
MemoryBlock,
@@ -41,10 +38,6 @@ import {
LETTA_API_BASE,
} from './conversation_utils.js';
// ESM-compatible __dirname
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
// Configuration
const DEBUG = process.env.LETTA_DEBUG === '1';
@@ -403,49 +396,6 @@ async function main(): Promise<void> {
console.log(outputs.join('\n\n'));
// Send user prompt to Letta early (gives Letta a head start while Claude processes)
if (sessionId && hookInput?.prompt && state) {
try {
// Ensure we have a conversation
const convId = await getOrCreateConversation(apiKey, agentId, sessionId, cwd, state);
// Get current transcript length for index tracking
const transcriptLength = hookInput.transcript_path
? countTranscriptLines(hookInput.transcript_path)
: 0;
// Format the prompt message
const promptMessage = `<claude_code_user_prompt>
<session_id>${sessionId}</session_id>
<prompt>${escapeXmlContent(hookInput.prompt)}</prompt>
<note>Early notification - Claude Code is processing this now. Full transcript with response will follow.</note>
</claude_code_user_prompt>`;
// Write payload for background worker
if (!fs.existsSync(TEMP_STATE_DIR)) {
fs.mkdirSync(TEMP_STATE_DIR, { recursive: true });
}
const payloadFile = path.join(TEMP_STATE_DIR, `prompt-${sessionId}-${Date.now()}.json`);
const payload = {
apiKey,
conversationId: convId,
sessionId,
message: promptMessage,
stateFile: getSyncStateFile(cwd, sessionId),
newLastProcessedIndex: transcriptLength > 0 ? transcriptLength - 1 : 0,
};
fs.writeFileSync(payloadFile, JSON.stringify(payload), 'utf-8');
// Spawn background worker
const workerScript = path.join(__dirname, 'send_worker.ts');
spawnSilentWorker(workerScript, payloadFile, cwd);
} catch (promptError) {
// Don't fail the sync if prompt sending fails - just log warning
console.error(`Warning: Failed to send prompt to Letta: ${promptError}`);
}
}
// Save state
if (state && sessionId) {
saveSyncState(cwd, state);

View File

@@ -1,295 +1,295 @@
/**
* Transcript Utilities
*
* Shared utilities for reading and formatting Claude Code transcripts.
* Used by send_messages_to_letta.ts and plan_checkpoint.ts.
*/
import * as fs from 'fs';
import * as readline from 'readline';
// Types for transcript parsing
export interface ContentBlock {
type: string;
text?: string;
thinking?: string;
name?: string; // tool name for tool_use
id?: string; // tool_use_id
input?: any; // tool input
tool_use_id?: string; // for tool_result
content?: string; // tool result content
is_error?: boolean; // tool error flag
}
export interface TranscriptMessage {
type: string;
role?: string;
content?: string | ContentBlock[];
message?: {
role?: string;
content?: string | ContentBlock[];
};
tool_name?: string;
tool_input?: any;
tool_result?: any;
timestamp?: string;
uuid?: string;
// Summary message fields
summary?: string;
// System message fields
subtype?: string;
stopReason?: string;
// File history fields
snapshot?: {
trackedFileBackups?: Record<string, any>;
};
}
export interface ExtractedContent {
text: string | null;
thinking: string | null;
toolUses: Array<{ name: string; input: any }>;
toolResults: Array<{ toolName: string; content: string; isError: boolean }>;
}
export type LogFn = (message: string) => void;
// Default no-op logger
const noopLog: LogFn = () => {};
/**
* Read transcript JSONL file and parse messages
*/
export async function readTranscript(transcriptPath: string, log: LogFn = noopLog): Promise<TranscriptMessage[]> {
if (!fs.existsSync(transcriptPath)) {
log(`Transcript file not found: ${transcriptPath}`);
return [];
}
const messages: TranscriptMessage[] = [];
const fileStream = fs.createReadStream(transcriptPath);
const rl = readline.createInterface({
input: fileStream,
crlfDelay: Infinity
});
for await (const line of rl) {
if (line.trim()) {
try {
messages.push(JSON.parse(line));
} catch (e) {
log(`Failed to parse transcript line: ${e}`);
}
}
}
return messages;
}
/**
* Extract different content types from a message
*/
export function extractAllContent(msg: TranscriptMessage): ExtractedContent {
const result: ExtractedContent = {
text: null,
thinking: null,
toolUses: [],
toolResults: [],
};
const content = msg.message?.content ?? msg.content;
if (typeof content === 'string') {
result.text = content;
return result;
}
if (Array.isArray(content)) {
const textParts: string[] = [];
const thinkingParts: string[] = [];
for (const block of content) {
if (block.type === 'text' && block.text) {
textParts.push(block.text);
} else if (block.type === 'thinking' && block.thinking) {
thinkingParts.push(block.thinking);
} else if (block.type === 'tool_use' && block.name) {
result.toolUses.push({
name: block.name,
input: block.input,
});
} else if (block.type === 'tool_result') {
const resultContent = typeof block.content === 'string'
? block.content
: JSON.stringify(block.content);
result.toolResults.push({
toolName: block.tool_use_id || 'unknown',
content: resultContent,
isError: block.is_error || false,
});
}
}
if (textParts.length > 0) {
result.text = textParts.join('\n');
}
if (thinkingParts.length > 0) {
result.thinking = thinkingParts.join('\n');
}
}
return result;
}
/**
* Truncate text to a maximum length
*/
export function truncate(text: string, maxLength: number): string {
if (text.length <= maxLength) return text;
return text.substring(0, maxLength) + '... [truncated]';
}
/**
* Format messages for Letta with rich context
*/
export function formatMessagesForLetta(
messages: TranscriptMessage[],
startIndex: number,
log: LogFn = noopLog
): Array<{role: string, text: string}> {
const formatted: Array<{role: string, text: string}> = [];
const toolNameMap: Map<string, string> = new Map(); // tool_use_id -> tool_name
log(`Formatting messages from index ${startIndex + 1} to ${messages.length - 1}`);
for (let i = startIndex + 1; i < messages.length; i++) {
const msg = messages[i];
log(` Message ${i}: type=${msg.type}`);
// Handle summary messages
if (msg.type === 'summary' && msg.summary) {
formatted.push({
role: 'system',
text: `[Session Summary]: ${msg.summary}`,
});
log(` -> Added summary`);
continue;
}
// Skip file-history-snapshot and system messages (internal)
if (msg.type === 'file-history-snapshot' || msg.type === 'system') {
continue;
}
// Handle user messages
if (msg.type === 'user') {
const extracted = extractAllContent(msg);
// User text input
if (extracted.text) {
formatted.push({ role: 'user', text: extracted.text });
log(` -> Added user message (${extracted.text.length} chars)`);
}
// Tool results (these come in user messages)
for (const toolResult of extracted.toolResults) {
const toolName = toolNameMap.get(toolResult.toolName) || toolResult.toolName;
const prefix = toolResult.isError ? '[Tool Error' : '[Tool Result';
const truncatedContent = truncate(toolResult.content, 1500);
formatted.push({
role: 'system',
text: `${prefix}: ${toolName}]\n${truncatedContent}`,
});
log(` -> Added tool result for ${toolName} (error: ${toolResult.isError})`);
}
}
// Handle assistant messages
else if (msg.type === 'assistant') {
const extracted = extractAllContent(msg);
// Track tool names for later result mapping
for (const toolUse of extracted.toolUses) {
if (toolUse.input?.id) {
toolNameMap.set(toolUse.input.id, toolUse.name);
}
}
// Assistant thinking (summarized)
if (extracted.thinking) {
const truncatedThinking = truncate(extracted.thinking, 500);
formatted.push({
role: 'assistant',
text: `[Thinking]: ${truncatedThinking}`,
});
log(` -> Added thinking (${extracted.thinking.length} chars, truncated to 500)`);
}
// Tool calls
for (const toolUse of extracted.toolUses) {
// Format tool input concisely
let inputSummary = '';
if (toolUse.input) {
if (toolUse.name === 'Read' && toolUse.input.file_path) {
inputSummary = toolUse.input.file_path;
} else if (toolUse.name === 'Edit' && toolUse.input.file_path) {
inputSummary = toolUse.input.file_path;
} else if (toolUse.name === 'Write' && toolUse.input.file_path) {
inputSummary = toolUse.input.file_path;
} else if (toolUse.name === 'Bash' && toolUse.input.command) {
inputSummary = truncate(toolUse.input.command, 100);
} else if (toolUse.name === 'Glob' && toolUse.input.pattern) {
inputSummary = toolUse.input.pattern;
} else if (toolUse.name === 'Grep' && toolUse.input.pattern) {
inputSummary = toolUse.input.pattern;
} else if (toolUse.name === 'WebFetch' && toolUse.input.url) {
inputSummary = toolUse.input.url;
} else if (toolUse.name === 'WebSearch' && toolUse.input.query) {
inputSummary = toolUse.input.query;
} else if (toolUse.name === 'Task' && toolUse.input.description) {
inputSummary = toolUse.input.description;
} else if (toolUse.name === 'AskUserQuestion' && toolUse.input.questions) {
// Summarize questions being asked
const questions = toolUse.input.questions;
if (Array.isArray(questions) && questions.length > 0) {
inputSummary = questions.map((q: any) => q.question || q.header || '').join('; ');
inputSummary = truncate(inputSummary, 100);
}
} else if (toolUse.name === 'ExitPlanMode') {
inputSummary = 'Exiting plan mode';
} else {
inputSummary = truncate(JSON.stringify(toolUse.input), 100);
}
}
formatted.push({
role: 'assistant',
text: `[Tool: ${toolUse.name}] ${inputSummary}`,
});
log(` -> Added tool use: ${toolUse.name}`);
}
// Assistant text response
if (extracted.text) {
formatted.push({ role: 'assistant', text: extracted.text });
log(` -> Added assistant text (${extracted.text.length} chars)`);
}
}
}
log(`Formatted ${formatted.length} messages total`);
return formatted;
}
/**
* Format messages as XML transcript entries for Letta API
*/
export function formatAsXmlTranscript(messages: Array<{role: string, text: string}>): string {
return messages.map(m => {
const role = m.role === 'user' ? 'user' : m.role === 'assistant' ? 'claude_code' : 'system';
// Escape XML special chars in text
const escaped = m.text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
return `<message role="${role}">\n${escaped}\n</message>`;
}).join('\n');
}
/**
* Transcript Utilities
*
* Shared utilities for reading and formatting Claude Code transcripts.
* Used by send_messages_to_letta.ts.
*/
import * as fs from 'fs';
import * as readline from 'readline';
// Types for transcript parsing
export interface ContentBlock {
type: string;
text?: string;
thinking?: string;
name?: string; // tool name for tool_use
id?: string; // tool_use_id
input?: any; // tool input
tool_use_id?: string; // for tool_result
content?: string; // tool result content
is_error?: boolean; // tool error flag
}
export interface TranscriptMessage {
type: string;
role?: string;
content?: string | ContentBlock[];
message?: {
role?: string;
content?: string | ContentBlock[];
};
tool_name?: string;
tool_input?: any;
tool_result?: any;
timestamp?: string;
uuid?: string;
// Summary message fields
summary?: string;
// System message fields
subtype?: string;
stopReason?: string;
// File history fields
snapshot?: {
trackedFileBackups?: Record<string, any>;
};
}
export interface ExtractedContent {
text: string | null;
thinking: string | null;
toolUses: Array<{ name: string; input: any }>;
toolResults: Array<{ toolName: string; content: string; isError: boolean }>;
}
export type LogFn = (message: string) => void;
// Default no-op logger
const noopLog: LogFn = () => {};
/**
* Read transcript JSONL file and parse messages
*/
export async function readTranscript(transcriptPath: string, log: LogFn = noopLog): Promise<TranscriptMessage[]> {
if (!fs.existsSync(transcriptPath)) {
log(`Transcript file not found: ${transcriptPath}`);
return [];
}
const messages: TranscriptMessage[] = [];
const fileStream = fs.createReadStream(transcriptPath);
const rl = readline.createInterface({
input: fileStream,
crlfDelay: Infinity
});
for await (const line of rl) {
if (line.trim()) {
try {
messages.push(JSON.parse(line));
} catch (e) {
log(`Failed to parse transcript line: ${e}`);
}
}
}
return messages;
}
/**
* Extract different content types from a message
*/
export function extractAllContent(msg: TranscriptMessage): ExtractedContent {
const result: ExtractedContent = {
text: null,
thinking: null,
toolUses: [],
toolResults: [],
};
const content = msg.message?.content ?? msg.content;
if (typeof content === 'string') {
result.text = content;
return result;
}
if (Array.isArray(content)) {
const textParts: string[] = [];
const thinkingParts: string[] = [];
for (const block of content) {
if (block.type === 'text' && block.text) {
textParts.push(block.text);
} else if (block.type === 'thinking' && block.thinking) {
thinkingParts.push(block.thinking);
} else if (block.type === 'tool_use' && block.name) {
result.toolUses.push({
name: block.name,
input: block.input,
});
} else if (block.type === 'tool_result') {
const resultContent = typeof block.content === 'string'
? block.content
: JSON.stringify(block.content);
result.toolResults.push({
toolName: block.tool_use_id || 'unknown',
content: resultContent,
isError: block.is_error || false,
});
}
}
if (textParts.length > 0) {
result.text = textParts.join('\n');
}
if (thinkingParts.length > 0) {
result.thinking = thinkingParts.join('\n');
}
}
return result;
}
/**
* Truncate text to a maximum length
*/
export function truncate(text: string, maxLength: number): string {
if (text.length <= maxLength) return text;
return text.substring(0, maxLength) + '... [truncated]';
}
/**
* Format messages for Letta with rich context
*/
export function formatMessagesForLetta(
messages: TranscriptMessage[],
startIndex: number,
log: LogFn = noopLog
): Array<{role: string, text: string}> {
const formatted: Array<{role: string, text: string}> = [];
const toolNameMap: Map<string, string> = new Map(); // tool_use_id -> tool_name
log(`Formatting messages from index ${startIndex + 1} to ${messages.length - 1}`);
for (let i = startIndex + 1; i < messages.length; i++) {
const msg = messages[i];
log(` Message ${i}: type=${msg.type}`);
// Handle summary messages
if (msg.type === 'summary' && msg.summary) {
formatted.push({
role: 'system',
text: `[Session Summary]: ${msg.summary}`,
});
log(` -> Added summary`);
continue;
}
// Skip file-history-snapshot and system messages (internal)
if (msg.type === 'file-history-snapshot' || msg.type === 'system') {
continue;
}
// Handle user messages
if (msg.type === 'user') {
const extracted = extractAllContent(msg);
// User text input
if (extracted.text) {
formatted.push({ role: 'user', text: extracted.text });
log(` -> Added user message (${extracted.text.length} chars)`);
}
// Tool results (these come in user messages)
for (const toolResult of extracted.toolResults) {
const toolName = toolNameMap.get(toolResult.toolName) || toolResult.toolName;
const prefix = toolResult.isError ? '[Tool Error' : '[Tool Result';
const truncatedContent = truncate(toolResult.content, 1500);
formatted.push({
role: 'system',
text: `${prefix}: ${toolName}]\n${truncatedContent}`,
});
log(` -> Added tool result for ${toolName} (error: ${toolResult.isError})`);
}
}
// Handle assistant messages
else if (msg.type === 'assistant') {
const extracted = extractAllContent(msg);
// Track tool names for later result mapping
for (const toolUse of extracted.toolUses) {
if (toolUse.input?.id) {
toolNameMap.set(toolUse.input.id, toolUse.name);
}
}
// Assistant thinking (summarized)
if (extracted.thinking) {
const truncatedThinking = truncate(extracted.thinking, 500);
formatted.push({
role: 'assistant',
text: `[Thinking]: ${truncatedThinking}`,
});
log(` -> Added thinking (${extracted.thinking.length} chars, truncated to 500)`);
}
// Tool calls
for (const toolUse of extracted.toolUses) {
// Format tool input concisely
let inputSummary = '';
if (toolUse.input) {
if (toolUse.name === 'Read' && toolUse.input.file_path) {
inputSummary = toolUse.input.file_path;
} else if (toolUse.name === 'Edit' && toolUse.input.file_path) {
inputSummary = toolUse.input.file_path;
} else if (toolUse.name === 'Write' && toolUse.input.file_path) {
inputSummary = toolUse.input.file_path;
} else if (toolUse.name === 'Bash' && toolUse.input.command) {
inputSummary = truncate(toolUse.input.command, 100);
} else if (toolUse.name === 'Glob' && toolUse.input.pattern) {
inputSummary = toolUse.input.pattern;
} else if (toolUse.name === 'Grep' && toolUse.input.pattern) {
inputSummary = toolUse.input.pattern;
} else if (toolUse.name === 'WebFetch' && toolUse.input.url) {
inputSummary = toolUse.input.url;
} else if (toolUse.name === 'WebSearch' && toolUse.input.query) {
inputSummary = toolUse.input.query;
} else if (toolUse.name === 'Task' && toolUse.input.description) {
inputSummary = toolUse.input.description;
} else if (toolUse.name === 'AskUserQuestion' && toolUse.input.questions) {
// Summarize questions being asked
const questions = toolUse.input.questions;
if (Array.isArray(questions) && questions.length > 0) {
inputSummary = questions.map((q: any) => q.question || q.header || '').join('; ');
inputSummary = truncate(inputSummary, 100);
}
} else if (toolUse.name === 'ExitPlanMode') {
inputSummary = 'Exiting plan mode';
} else {
inputSummary = truncate(JSON.stringify(toolUse.input), 100);
}
}
formatted.push({
role: 'assistant',
text: `[Tool: ${toolUse.name}] ${inputSummary}`,
});
log(` -> Added tool use: ${toolUse.name}`);
}
// Assistant text response
if (extracted.text) {
formatted.push({ role: 'assistant', text: extracted.text });
log(` -> Added assistant text (${extracted.text.length} chars)`);
}
}
}
log(`Formatted ${formatted.length} messages total`);
return formatted;
}
/**
* Format messages as XML transcript entries for Letta API
*/
export function formatAsXmlTranscript(messages: Array<{role: string, text: string}>): string {
return messages.map(m => {
const role = m.role === 'user' ? 'user' : m.role === 'assistant' ? 'claude_code' : 'system';
// Escape XML special chars in text
const escaped = m.text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
return `<message role="${role}">\n${escaped}\n</message>`;
}).join('\n');
}