mirror of
https://github.com/letta-ai/claude-subconscious.git
synced 2026-04-25 17:04:56 +02:00
feat: add Letta Code SDK transport for client-side tool access (#19)
Give the Subconscious agent client-side tool access (Read, Grep, Glob, web_search) via the Letta Code SDK. Instead of being limited to memory operations, Sub can now read files and search the web while processing transcripts. Architecture: - New send_worker_sdk.ts uses resumeSession() from @letta-ai/letta-code-sdk - send_messages_to_letta.ts routes to SDK or legacy worker based on LETTA_SDK_TOOLS env var (read-only | full | off) - Stop hook is now async (won't block Claude Code) - Legacy raw API path preserved for LETTA_SDK_TOOLS=off New env var: LETTA_SDK_TOOLS - read-only (default): Read, Grep, Glob, web_search, fetch_webpage - full: all tools - off: legacy memory-only behavior Closes #19 Written by Cameron ◯ Letta Code "The best interface is no interface." - Golden Krishna
This commit is contained in:
25
README.md
25
README.md
@@ -112,6 +112,7 @@ 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"
|
||||
```
|
||||
|
||||
- `LETTA_MODE` - Controls what gets injected. `whisper` (default, messages only), `full` (blocks + messages), `off` (disable). See [Modes](#modes).
|
||||
@@ -121,6 +122,7 @@ export LETTA_CHECKPOINT_MODE="blocking" # Or "async", "off"
|
||||
- `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).
|
||||
|
||||
### Modes
|
||||
|
||||
@@ -303,23 +305,38 @@ 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.
|
||||
|
||||
**Configuration via `LETTA_SDK_TOOLS`:**
|
||||
|
||||
| Mode | Tools Available | Use Case |
|
||||
|------|----------------|----------|
|
||||
| `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 |
|
||||
|
||||
> **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.
|
||||
|
||||
### Stop
|
||||
|
||||
Uses a **fire-and-forget** pattern to avoid timeout issues:
|
||||
Uses an **async hook** pattern — runs in the background without blocking Claude Code:
|
||||
|
||||
1. Main hook (`send_messages_to_letta.ts`) runs quickly:
|
||||
- Parses the session transcript (JSONL format)
|
||||
- Extracts user messages, assistant responses, thinking blocks, and tool usage
|
||||
- Writes payload to a temp file
|
||||
- Spawns detached background worker (`send_worker.ts`)
|
||||
- Spawns detached background worker
|
||||
- Exits immediately
|
||||
|
||||
2. Background worker runs independently:
|
||||
- Sends messages to Letta agent
|
||||
- **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)
|
||||
- Updates state on success
|
||||
- Cleans up temp file
|
||||
|
||||
This ensures the hook never times out, even when the Letta API is slow.
|
||||
The Stop hook runs as an async hook, so it never blocks Claude Code.
|
||||
|
||||
## State Management
|
||||
|
||||
|
||||
135
hooks/hooks.json
135
hooks/hooks.json
@@ -1,67 +1,68 @@
|
||||
{
|
||||
"hooks": {
|
||||
"SessionStart": [
|
||||
{
|
||||
"matcher": "*",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/silent-npx.cjs\" tsx \"${CLAUDE_PLUGIN_ROOT}/scripts/session_start.ts\"",
|
||||
"timeout": 5
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/silent-npx.cjs\" tsx \"${CLAUDE_PLUGIN_ROOT}/scripts/sync_letta_memory.ts\"",
|
||||
"timeout": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"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": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/silent-npx.cjs\" tsx \"${CLAUDE_PLUGIN_ROOT}/scripts/pretool_sync.ts\"",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"UserPromptSubmit": [
|
||||
{
|
||||
"matcher": "*",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/silent-npx.cjs\" tsx \"${CLAUDE_PLUGIN_ROOT}/scripts/sync_letta_memory.ts\"",
|
||||
"timeout": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"Stop": [
|
||||
{
|
||||
"matcher": "*",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/silent-npx.cjs\" tsx \"${CLAUDE_PLUGIN_ROOT}/scripts/send_messages_to_letta.ts\"",
|
||||
"timeout": 15
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
{
|
||||
"hooks": {
|
||||
"SessionStart": [
|
||||
{
|
||||
"matcher": "*",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/silent-npx.cjs\" tsx \"${CLAUDE_PLUGIN_ROOT}/scripts/session_start.ts\"",
|
||||
"timeout": 5
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/silent-npx.cjs\" tsx \"${CLAUDE_PLUGIN_ROOT}/scripts/sync_letta_memory.ts\"",
|
||||
"timeout": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"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": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/silent-npx.cjs\" tsx \"${CLAUDE_PLUGIN_ROOT}/scripts/pretool_sync.ts\"",
|
||||
"timeout": 5
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"UserPromptSubmit": [
|
||||
{
|
||||
"matcher": "*",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/silent-npx.cjs\" tsx \"${CLAUDE_PLUGIN_ROOT}/scripts/sync_letta_memory.ts\"",
|
||||
"timeout": 10
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"Stop": [
|
||||
{
|
||||
"matcher": "*",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/silent-npx.cjs\" tsx \"${CLAUDE_PLUGIN_ROOT}/scripts/send_messages_to_letta.ts\"",
|
||||
"timeout": 120,
|
||||
"async": true
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
3254
package-lock.json
generated
Normal file
3254
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -33,6 +33,7 @@
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@letta-ai/letta-code-sdk": "^0.1.0",
|
||||
"tsx": "^4.7.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -59,6 +59,30 @@ export function getTempStateDir(): string {
|
||||
return path.join(os.tmpdir(), `letta-claude-sync-${uid}`);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// SDK Tools Configuration
|
||||
// ============================================
|
||||
|
||||
export type SdkToolsMode = 'read-only' | 'full' | 'off';
|
||||
|
||||
/** Read-only tool set: safe defaults for background Sub execution */
|
||||
export const SDK_TOOLS_READ_ONLY = ['Read', 'Grep', 'Glob', 'web_search', 'fetch_webpage'];
|
||||
|
||||
/** Tools to always block in SDK sessions (require interactive input) */
|
||||
export const SDK_TOOLS_BLOCKED = ['AskUserQuestion', 'EnterPlanMode', 'ExitPlanMode'];
|
||||
|
||||
/**
|
||||
* 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)
|
||||
*/
|
||||
export function getSdkToolsMode(): SdkToolsMode {
|
||||
const mode = process.env.LETTA_SDK_TOOLS?.toLowerCase();
|
||||
if (mode === 'full' || mode === 'off') return mode;
|
||||
return 'read-only';
|
||||
}
|
||||
|
||||
// Types
|
||||
export interface SyncState {
|
||||
lastProcessedIndex: number;
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
LogFn,
|
||||
getMode,
|
||||
getTempStateDir,
|
||||
getSdkToolsMode,
|
||||
} from './conversation_utils.js';
|
||||
import {
|
||||
readTranscript,
|
||||
@@ -322,23 +323,47 @@ Write your response as if speaking directly to Claude Code.
|
||||
</instructions>
|
||||
</claude_code_session_update>`;
|
||||
|
||||
// Write payload to temp file for the worker
|
||||
const payloadFile = path.join(TEMP_STATE_DIR, `payload-${hookInput.session_id}-${Date.now()}.json`);
|
||||
const payload = {
|
||||
apiKey,
|
||||
conversationId,
|
||||
sessionId: hookInput.session_id,
|
||||
message: userMessage,
|
||||
stateFile: getSyncStateFile(hookInput.cwd, hookInput.session_id),
|
||||
newLastProcessedIndex: messages.length - 1,
|
||||
};
|
||||
fs.writeFileSync(payloadFile, JSON.stringify(payload), 'utf-8');
|
||||
log(`Wrote payload to ${payloadFile}`);
|
||||
// Decide transport: SDK (with client-side tools) or legacy (raw API)
|
||||
const sdkToolsMode = getSdkToolsMode();
|
||||
log(`SDK tools mode: ${sdkToolsMode}`);
|
||||
|
||||
// Spawn worker as detached background process
|
||||
const workerScript = path.join(__dirname, 'send_worker.ts');
|
||||
const child = spawnSilentWorker(workerScript, payloadFile, hookInput.cwd);
|
||||
log(`Spawned background worker (PID: ${child.pid})`);
|
||||
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 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})`);
|
||||
}
|
||||
|
||||
log('Hook completed (worker running in background)');
|
||||
|
||||
|
||||
146
scripts/send_worker_sdk.ts
Normal file
146
scripts/send_worker_sdk.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
/**
|
||||
* SDK-based background worker that sends messages to Letta via Letta Code SDK.
|
||||
* Gives the Subconscious agent client-side tool access (Read, Grep, Glob, etc.).
|
||||
*
|
||||
* Spawned by send_messages_to_letta.ts as a detached process.
|
||||
* Falls back gracefully if the SDK is not available.
|
||||
*
|
||||
* Usage: npx tsx send_worker_sdk.ts <payload_file>
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
|
||||
const uid = typeof process.getuid === 'function' ? process.getuid() : process.pid;
|
||||
const TEMP_STATE_DIR = path.join(os.tmpdir(), `letta-claude-sync-${uid}`);
|
||||
const LOG_FILE = path.join(TEMP_STATE_DIR, 'send_worker_sdk.log');
|
||||
|
||||
interface SdkPayload {
|
||||
agentId: string;
|
||||
sessionId: string;
|
||||
message: string;
|
||||
stateFile: string;
|
||||
newLastProcessedIndex: number;
|
||||
cwd: string;
|
||||
sdkToolsMode: 'read-only' | 'full';
|
||||
}
|
||||
|
||||
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 sendViaSdk(payload: SdkPayload): Promise<boolean> {
|
||||
log(`Loading Letta Code SDK...`);
|
||||
|
||||
// Dynamic import so this file can be parsed even if SDK isn't installed
|
||||
const { resumeSession } = await import('@letta-ai/letta-code-sdk');
|
||||
|
||||
// Configure tool restrictions based on mode
|
||||
const readOnlyTools = ['Read', 'Grep', 'Glob', 'web_search', 'fetch_webpage'];
|
||||
const blockedTools = ['AskUserQuestion', 'EnterPlanMode', 'ExitPlanMode'];
|
||||
|
||||
const sessionOptions: Record<string, unknown> = {
|
||||
disallowedTools: blockedTools,
|
||||
permissionMode: 'bypassPermissions',
|
||||
cwd: payload.cwd,
|
||||
skillSources: [], // Sub doesn't need skills
|
||||
systemInfoReminder: false, // reduce noise
|
||||
sleeptime: { trigger: 'off' }, // don't recurse sleeptime
|
||||
};
|
||||
|
||||
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})`);
|
||||
log(` cwd: ${payload.cwd}`);
|
||||
log(` allowedTools: ${payload.sdkToolsMode === 'read-only' ? readOnlyTools.join(', ') : 'all'}`);
|
||||
|
||||
const session = resumeSession(payload.agentId, sessionOptions);
|
||||
|
||||
try {
|
||||
log(`Sending message (${payload.message.length} chars)...`);
|
||||
await session.send(payload.message);
|
||||
|
||||
// Stream and capture the response
|
||||
let assistantResponse = '';
|
||||
let messageCount = 0;
|
||||
|
||||
for await (const msg of session.stream()) {
|
||||
messageCount++;
|
||||
if (msg.type === 'assistant' && msg.content) {
|
||||
assistantResponse += msg.content;
|
||||
log(` Assistant chunk: ${msg.content.substring(0, 100)}...`);
|
||||
}
|
||||
}
|
||||
|
||||
log(`Stream complete: ${messageCount} messages, assistant response: ${assistantResponse.length} chars`);
|
||||
|
||||
// The SDK session sends the message to the Letta agent which processes it
|
||||
// and generates a response. The response is automatically stored in the
|
||||
// agent's conversation history on the Letta server. The existing
|
||||
// pretool_sync / sync_letta_memory flow will pick it up and inject it
|
||||
// into Claude's context on the next prompt.
|
||||
|
||||
return true;
|
||||
|
||||
} finally {
|
||||
session.close();
|
||||
log('SDK session closed');
|
||||
}
|
||||
}
|
||||
|
||||
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(`SDK Worker started with payload: ${payloadFile}`);
|
||||
|
||||
try {
|
||||
if (!fs.existsSync(payloadFile)) {
|
||||
log(`ERROR: Payload file not found: ${payloadFile}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const payload: SdkPayload = JSON.parse(fs.readFileSync(payloadFile, 'utf-8'));
|
||||
log(`Loaded payload for session ${payload.sessionId}`);
|
||||
|
||||
const success = await sendViaSdk(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('SDK Worker completed successfully');
|
||||
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
log(`ERROR: ${errorMessage}`);
|
||||
if (error instanceof Error && error.stack) {
|
||||
log(`Stack: ${error.stack}`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
Reference in New Issue
Block a user