feat: add checkpoint hooks and startup splash screen

- Add PreToolUse hooks for AskUserQuestion and ExitPlanMode to send
  transcripts to Letta at natural pause points (plan_checkpoint.ts)
- Extract shared transcript utilities into transcript_utils.ts
- Add LETTA_CHECKPOINT_MODE env var (blocking/async/off)
- Add startup splash screen with agent info, settings, and links
- Write to /dev/tty to show splash in terminal (bypasses Claude capture)
- Update README with checkpoint hooks documentation
This commit is contained in:
Cameron
2026-03-04 12:36:01 -08:00
parent 2e93f2aff1
commit 890872368c
6 changed files with 801 additions and 261 deletions

View File

@@ -111,6 +111,7 @@ 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"
```
- `LETTA_MODE` - Controls what gets injected. `whisper` (default, messages only), `full` (blocks + messages), `off` (disable). See [Modes](#modes).
@@ -119,6 +120,8 @@ export LETTA_HOME="$HOME" # Consolidate .letta state to ~/.letta/
- `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).
### Modes
The `LETTA_MODE` environment variable controls what gets injected into Claude's context:
@@ -250,7 +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` | `pretool_sync.ts` | 5s | Mid-workflow updates via `additionalContext` |
| `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 |
### SessionStart
@@ -276,6 +280,29 @@ 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>
```
### Stop
Uses a **fire-and-forget** pattern to avoid timeout issues:
@@ -309,6 +336,7 @@ 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

View File

@@ -18,6 +18,16 @@
}
],
"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": [

379
scripts/plan_checkpoint.ts Normal file
View File

@@ -0,0 +1,379 @@
#!/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

@@ -23,7 +23,6 @@
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 {
@@ -38,6 +37,11 @@ import {
getMode,
getTempStateDir,
} from './conversation_utils.js';
import {
readTranscript,
formatMessagesForLetta,
TranscriptMessage,
} from './transcript_utils.js';
// ESM-compatible __dirname
const __filename = fileURLToPath(import.meta.url);
@@ -55,44 +59,6 @@ interface HookInput {
hook_event_name?: string;
}
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
}
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>;
};
}
/**
* Ensure temp log directory exists
*/
@@ -136,224 +102,6 @@ async function readHookInput(): Promise<HookInput> {
});
}
/**
* Read transcript JSONL file and parse messages
*/
async function readTranscript(transcriptPath: string): 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
*/
interface ExtractedContent {
text: string | null;
thinking: string | null;
toolUses: Array<{ name: string; input: any }>;
toolResults: Array<{ toolName: string; content: string; isError: boolean }>;
}
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
*/
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
*/
function formatMessagesForLetta(messages: TranscriptMessage[], startIndex: number): 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 {
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;
}
interface SendResult {
skipped: boolean;
@@ -515,7 +263,7 @@ async function main(): Promise<void> {
// Read transcript
log(`Reading transcript from: ${hookInput.transcript_path}`);
const messages = await readTranscript(hookInput.transcript_path);
const messages = await readTranscript(hookInput.transcript_path, log);
log(`Found ${messages.length} messages in transcript`);
if (messages.length === 0) {
@@ -535,7 +283,7 @@ async function main(): Promise<void> {
const state = loadSyncState(hookInput.cwd, hookInput.session_id, log);
// Format new messages
const newMessages = formatMessagesForLetta(messages, state.lastProcessedIndex);
const newMessages = formatMessagesForLetta(messages, state.lastProcessedIndex, log);
if (newMessages.length === 0) {
log('No new messages to send after formatting');

View File

@@ -28,6 +28,7 @@ import { getAgentId } from './agent_config.js';
import {
cleanLettaFromClaudeMd,
createConversation,
fetchAgent,
getMode,
getTempStateDir,
} from './conversation_utils.js';
@@ -241,9 +242,60 @@ async function main(): Promise<void> {
process.exit(1);
}
// Try to open TTY for user-visible output (bypasses Claude's capture)
let tty: fs.WriteStream | null = null;
try {
tty = fs.createWriteStream('/dev/tty');
} catch {
// TTY not available (e.g., non-interactive session)
}
const writeTty = (text: string) => {
if (tty) tty.write(text);
};
try {
// Show initial connecting message
writeTty('\x1b[2m'); // Dim
writeTty(' \u25E6 Letta Subconscious\n'); // Small circle
writeTty('\x1b[0m'); // Reset
// Get agent ID (from env, saved config, or auto-import)
const agentId = await getAgentId(apiKey, log);
// Fetch agent details for display
writeTty(` \x1b[2m\u25CB Connecting...\x1b[0m`);
const agent = await fetchAgent(apiKey, agentId);
const agentName = agent.name || 'Unnamed Agent';
const modelHandle = (agent as any).llm_config?.handle || (agent as any).llm_config?.model || 'unknown';
// Clear and show full splash
writeTty('\r\x1b[K'); // Clear current line
writeTty('\n');
writeTty('\x1b[1m'); // Bold
writeTty(` ${agentName}\n`);
writeTty('\x1b[0m'); // Reset
writeTty('\x1b[2m'); // Dim
writeTty(` ${agentId}\n`);
writeTty('\n');
// Settings
const checkpointMode = process.env.LETTA_CHECKPOINT_MODE || 'blocking';
const baseUrl = process.env.LETTA_BASE_URL || 'https://api.letta.com';
writeTty(` Model: ${modelHandle}\n`);
writeTty(` Mode: ${mode}\n`);
writeTty(` Checkpoint: ${checkpointMode}\n`);
if (process.env.LETTA_BASE_URL) {
writeTty(` Server: ${baseUrl}\n`);
}
if (process.env.LETTA_HOME) {
writeTty(` Home: ${process.env.LETTA_HOME}\n`);
}
writeTty('\n');
writeTty(' Learn about configuration settings:\n');
writeTty(' github.com/letta-ai/claude-subconscious\n');
writeTty('\x1b[0m'); // Reset
writeTty('\n');
// Read hook input
log('Reading hook input from stdin...');
const hookInput = await readHookInput();
@@ -305,12 +357,40 @@ async function main(): Promise<void> {
// Send session start message
await sendSessionStartMessage(apiKey, conversationId, hookInput.session_id, hookInput.cwd);
// Show conversation link (only for hosted Letta)
const isHosted = !process.env.LETTA_BASE_URL;
if (isHosted) {
const convUrl = `https://app.letta.com/agents/${agentId}?conversation=${conversationId}`;
writeTty('\x1b[2m'); // Dim
writeTty(' View the subconscious agent:\n');
writeTty(` ${convUrl}\n`);
writeTty('\x1b[0m'); // Reset
writeTty('\n');
}
// Discord link
writeTty('\x1b[2m'); // Dim
writeTty(' Come talk to us on Discord:\n');
writeTty(' https://discord.gg/letta\n');
writeTty('\x1b[0m'); // Reset
writeTty('\n');
// Close TTY
if (tty) tty.end();
log('Completed successfully');
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
log(`ERROR: ${errorMessage}`);
console.error(`Error in session start hook: ${errorMessage}`);
// Show error to user
writeTty('\r\x1b[K'); // Clear current line
writeTty('\x1b[31m'); // Red
writeTty(` Letta error: ${errorMessage}\n`);
writeTty('\x1b[0m'); // Reset
if (tty) tty.end();
process.exit(1);
}
}

295
scripts/transcript_utils.ts Normal file
View File

@@ -0,0 +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');
}