mirror of
https://github.com/letta-ai/claude-subconscious.git
synced 2026-04-25 17:04:56 +02:00
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:
@@ -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",
|
||||
|
||||
48
README.md
48
README.md
@@ -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
@@ -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": [
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
// ============================================
|
||||
|
||||
@@ -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();
|
||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
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)');
|
||||
|
||||
|
||||
@@ -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();
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
return `<message role="${role}">\n${escaped}\n</message>`;
|
||||
}).join('\n');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user