mirror of
https://github.com/letta-ai/claude-subconscious.git
synced 2026-04-25 17:04:56 +02:00
#47: Skip /dev/tty entirely on Windows — it resolves to C:\dev\tty which doesn't exist. The .on('error') handler from v2.1.1 catches the async error on Linux, but on Windows we shouldn't even attempt it. #48: Expand common shell syntax ($HOME, ${HOME}, ~) in LETTA_HOME. When set via Claude Code settings.json, env vars aren't shell-expanded, so "$HOME" becomes a literal directory name. expandPath() now resolves these to os.homedir(). Fixes both getDurableStateDir() call sites and the splash screen display. Fixes #47, fixes #48. Written by Cameron ◯ Letta Code "Defensive programming is the art of expecting the unexpected." - Unknown
420 lines
13 KiB
TypeScript
420 lines
13 KiB
TypeScript
#!/usr/bin/env npx tsx
|
|
/**
|
|
* Session Start Hook Script
|
|
*
|
|
* Notifies Letta agent when a new Claude Code session begins.
|
|
* This script is designed to run as a Claude Code SessionStart hook.
|
|
*
|
|
* Environment Variables:
|
|
* LETTA_API_KEY - API key for Letta authentication
|
|
* LETTA_AGENT_ID - Agent ID to send messages to
|
|
*
|
|
* Hook Input (via stdin):
|
|
* - session_id: Current session ID
|
|
* - cwd: Current working directory
|
|
* - hook_event_name: "SessionStart"
|
|
*
|
|
* Exit Codes:
|
|
* 0 - Success
|
|
* 1 - Non-blocking error
|
|
*
|
|
* Log file: $TMPDIR/letta-claude-sync-$UID/session_start.log
|
|
*/
|
|
|
|
import * as fs from 'fs';
|
|
import * as os from 'os';
|
|
import * as path from 'path';
|
|
import { getAgentId } from './agent_config.js';
|
|
import {
|
|
cleanLettaFromClaudeMd,
|
|
createConversation,
|
|
fetchAgent,
|
|
getMode,
|
|
getTempStateDir,
|
|
getSdkToolsMode,
|
|
expandPath,
|
|
} from './conversation_utils.js';
|
|
import { buildLettaApiUrl } from './letta_api_url.js';
|
|
|
|
// Configuration
|
|
const TEMP_STATE_DIR = getTempStateDir();
|
|
const LOG_FILE = path.join(TEMP_STATE_DIR, 'session_start.log');
|
|
|
|
interface HookInput {
|
|
session_id: string;
|
|
cwd: string;
|
|
hook_event_name?: string;
|
|
}
|
|
|
|
interface ConversationEntry {
|
|
conversationId: string;
|
|
agentId: string;
|
|
}
|
|
|
|
// Support both old format (string) and new format (object) for backward compatibility
|
|
interface ConversationsMap {
|
|
[sessionId: string]: string | ConversationEntry;
|
|
}
|
|
|
|
interface Conversation {
|
|
id: string;
|
|
agent_id: string;
|
|
created_at?: string;
|
|
}
|
|
|
|
// Durable storage in .letta directory
|
|
// If LETTA_HOME is set, use that as the base instead of cwd
|
|
function getDurableStateDir(cwd: string): string {
|
|
const raw = process.env.LETTA_HOME || cwd;
|
|
const base = process.env.LETTA_HOME ? expandPath(raw) : raw;
|
|
return path.join(base, '.letta', 'claude');
|
|
}
|
|
|
|
function getConversationsFile(cwd: string): string {
|
|
return path.join(getDurableStateDir(cwd), 'conversations.json');
|
|
}
|
|
|
|
function getSyncStateFile(cwd: string, sessionId: string): string {
|
|
return path.join(getDurableStateDir(cwd), `session-${sessionId}.json`);
|
|
}
|
|
|
|
/**
|
|
* Ensure directories exist
|
|
*/
|
|
function ensureLogDir(): void {
|
|
if (!fs.existsSync(TEMP_STATE_DIR)) {
|
|
fs.mkdirSync(TEMP_STATE_DIR, { recursive: true });
|
|
}
|
|
}
|
|
|
|
function ensureDurableStateDir(cwd: string): void {
|
|
const dir = getDurableStateDir(cwd);
|
|
if (!fs.existsSync(dir)) {
|
|
fs.mkdirSync(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);
|
|
}
|
|
|
|
/**
|
|
* 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);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Load conversations mapping
|
|
*/
|
|
function loadConversationsMap(cwd: string): ConversationsMap {
|
|
const filePath = getConversationsFile(cwd);
|
|
if (fs.existsSync(filePath)) {
|
|
try {
|
|
return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
|
} catch (e) {
|
|
log(`Failed to load conversations map: ${e}`);
|
|
}
|
|
}
|
|
return {};
|
|
}
|
|
|
|
/**
|
|
* Save conversations mapping
|
|
*/
|
|
function saveConversationsMap(cwd: string, map: ConversationsMap): void {
|
|
ensureDurableStateDir(cwd);
|
|
fs.writeFileSync(getConversationsFile(cwd), JSON.stringify(map, null, 2), 'utf-8');
|
|
}
|
|
|
|
/**
|
|
* Save session state
|
|
*/
|
|
function saveSessionState(cwd: string, sessionId: string, conversationId: string): void {
|
|
ensureDurableStateDir(cwd);
|
|
const state = {
|
|
sessionId,
|
|
conversationId,
|
|
lastProcessedIndex: -1,
|
|
startedAt: new Date().toISOString(),
|
|
};
|
|
fs.writeFileSync(getSyncStateFile(cwd, sessionId), JSON.stringify(state, null, 2), 'utf-8');
|
|
}
|
|
|
|
/**
|
|
* Send session start message to Letta
|
|
*/
|
|
async function sendSessionStartMessage(
|
|
apiKey: string,
|
|
conversationId: string,
|
|
sessionId: string,
|
|
cwd: string
|
|
): Promise<void> {
|
|
const url = buildLettaApiUrl(`/conversations/${conversationId}/messages`);
|
|
|
|
const projectName = path.basename(cwd);
|
|
const timestamp = new Date().toISOString();
|
|
|
|
const sdkToolsMode = getSdkToolsMode();
|
|
const toolAccessDescription = sdkToolsMode === 'full'
|
|
? 'Full tool access enabled — you can Read, Grep, Glob, Edit, Write, Bash, and search the web.'
|
|
: sdkToolsMode === 'read-only'
|
|
? 'Read-only tool access — you can Read, Grep, Glob files and search the web. No writes.'
|
|
: 'Listen-only mode — no client-side tools. You can only update your memory blocks.';
|
|
|
|
const message = `<claude_code_session_start>
|
|
<project>${projectName}</project>
|
|
<path>${cwd}</path>
|
|
<session_id>${sessionId}</session_id>
|
|
<timestamp>${timestamp}</timestamp>
|
|
<sdk_tools_mode>${sdkToolsMode}</sdk_tools_mode>
|
|
|
|
<context>
|
|
A new Claude Code session has begun. I'll be sending you updates as the session progresses.
|
|
|
|
Tool access: ${toolAccessDescription}
|
|
${sdkToolsMode !== 'off' ? `Use your tools to explore the codebase at ${cwd} when processing transcripts.` : ''}
|
|
</context>
|
|
</claude_code_session_start>`;
|
|
|
|
log(`Sending session start 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.ok) {
|
|
const errorText = await response.text();
|
|
throw new Error(`Failed to send message: ${response.status} ${errorText}`);
|
|
}
|
|
|
|
// Consume stream minimally
|
|
const reader = response.body?.getReader();
|
|
if (reader) {
|
|
try {
|
|
await reader.read();
|
|
} finally {
|
|
reader.cancel();
|
|
}
|
|
}
|
|
|
|
log(`Session start message sent successfully`);
|
|
}
|
|
|
|
/**
|
|
* Main function
|
|
*/
|
|
async function main(): Promise<void> {
|
|
log('='.repeat(60));
|
|
log('session_start.ts started');
|
|
|
|
const mode = getMode();
|
|
log(`Mode: ${mode}`);
|
|
if (mode === 'off') {
|
|
log('Mode is off, exiting');
|
|
process.exit(0);
|
|
}
|
|
|
|
const apiKey = process.env.LETTA_API_KEY;
|
|
|
|
if (!apiKey) {
|
|
log('ERROR: LETTA_API_KEY not set');
|
|
console.error('Error: LETTA_API_KEY must be set');
|
|
process.exit(1);
|
|
}
|
|
|
|
// Try to open TTY for user-visible output (bypasses Claude's capture)
|
|
// Skip on Windows — /dev/tty resolves to C:\dev\tty which doesn't exist
|
|
let tty: fs.WriteStream | null = null;
|
|
if (process.platform !== 'win32') {
|
|
try {
|
|
tty = fs.createWriteStream('/dev/tty');
|
|
tty.on('error', () => { tty = null; }); // Handle async ENXIO when /dev/tty unavailable
|
|
} catch {
|
|
// TTY not available (e.g., non-interactive session)
|
|
}
|
|
}
|
|
|
|
const writeTty = (text: string) => {
|
|
if (tty) tty.write(text);
|
|
};
|
|
|
|
try {
|
|
// Show initial connecting message with mascot
|
|
writeTty('\n');
|
|
writeTty('\x1b[1m Claude Subconscious\x1b[0m\n');
|
|
writeTty('\n');
|
|
writeTty('\x1b[35m'); // Purple
|
|
writeTty(' ▐\x1b[31m▛\x1b[35m███\x1b[31m▜\x1b[35m▌\n');
|
|
writeTty(' ▝▜█████▛▘\n');
|
|
writeTty(' ▘▘ ▝▝\n');
|
|
writeTty('\x1b[0m'); // Reset
|
|
writeTty('\x1b[2m Connecting...\x1b[0m');
|
|
|
|
// Get agent ID (from env, saved config, or auto-import)
|
|
const agentId = await getAgentId(apiKey, log);
|
|
|
|
// Fetch agent details for display
|
|
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 connecting message and show info
|
|
writeTty('\r\x1b[K'); // Clear current line
|
|
writeTty('\n Agent information:\n');
|
|
writeTty('\x1b[1m'); // Bold
|
|
writeTty(` ${agentName}\n`);
|
|
writeTty('\x1b[0m'); // Reset
|
|
writeTty('\x1b[2m'); // Dim
|
|
writeTty(` ${agentId}\n`);
|
|
writeTty('\n');
|
|
|
|
// Settings
|
|
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(` SDK Tools: ${sdkTools}\n`);
|
|
if (process.env.LETTA_BASE_URL) {
|
|
writeTty(` Server: ${baseUrl}\n`);
|
|
}
|
|
if (process.env.LETTA_HOME) {
|
|
writeTty(` Home: ${expandPath(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();
|
|
log(`Hook input: session_id=${hookInput.session_id}, cwd=${hookInput.cwd}`);
|
|
|
|
// Check if conversation already exists for this session
|
|
const conversationsMap = loadConversationsMap(hookInput.cwd);
|
|
|
|
let conversationId: string;
|
|
const cached = conversationsMap[hookInput.session_id];
|
|
|
|
if (cached) {
|
|
// Parse both old format (string) and new format (object)
|
|
const entry = typeof cached === 'string'
|
|
? { conversationId: cached, agentId: null as string | null }
|
|
: cached;
|
|
|
|
if (entry.agentId && entry.agentId !== agentId) {
|
|
// Agent ID changed - clear stale entry and create new conversation
|
|
log(`Agent ID changed (${entry.agentId} -> ${agentId}), clearing stale conversation`);
|
|
delete conversationsMap[hookInput.session_id];
|
|
conversationId = await createConversation(apiKey, agentId, log);
|
|
conversationsMap[hookInput.session_id] = { conversationId, agentId };
|
|
saveConversationsMap(hookInput.cwd, conversationsMap);
|
|
} else if (!entry.agentId) {
|
|
// Old format without agentId - upgrade by recreating
|
|
log(`Upgrading old format entry (no agentId stored), creating new conversation`);
|
|
delete conversationsMap[hookInput.session_id];
|
|
conversationId = await createConversation(apiKey, agentId, log);
|
|
conversationsMap[hookInput.session_id] = { conversationId, agentId };
|
|
saveConversationsMap(hookInput.cwd, conversationsMap);
|
|
} else {
|
|
// Valid entry with matching agentId - reuse
|
|
conversationId = entry.conversationId;
|
|
log(`Reusing existing conversation: ${conversationId}`);
|
|
}
|
|
} else {
|
|
// No existing entry - create new conversation
|
|
conversationId = await createConversation(apiKey, agentId, log);
|
|
conversationsMap[hookInput.session_id] = { conversationId, agentId };
|
|
saveConversationsMap(hookInput.cwd, conversationsMap);
|
|
}
|
|
|
|
// Save session state
|
|
saveSessionState(hookInput.cwd, hookInput.session_id, conversationId);
|
|
|
|
// Clean up any existing <letta> section from CLAUDE.md (legacy migration)
|
|
log('Cleaning up any legacy CLAUDE.md content...');
|
|
cleanLettaFromClaudeMd(hookInput.cwd);
|
|
|
|
// Also clean the global ~/.claude/CLAUDE.md (may have bloat from pre-v1.3.0)
|
|
const homeDir = process.env.HOME || os.homedir();
|
|
if (homeDir !== hookInput.cwd) {
|
|
log('Cleaning up global ~/.claude/CLAUDE.md...');
|
|
cleanLettaFromClaudeMd(homeDir);
|
|
}
|
|
log('CLAUDE.md cleanup done');
|
|
|
|
// Show conversation link (only for hosted Letta) - print before blocking send
|
|
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 before potentially slow network call
|
|
if (tty) tty.end();
|
|
|
|
// Send session start message (may take a while, but TTY output is done)
|
|
await sendSessionStartMessage(apiKey, conversationId, hookInput.session_id, hookInput.cwd);
|
|
|
|
log('Completed successfully');
|
|
|
|
} catch (error) {
|
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
log(`ERROR: ${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);
|
|
}
|
|
}
|
|
|
|
main();
|