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