mempalace
Local-first memory for Claude Code. It stores conversations verbatim in a private palace you can search before answering about past work, people, projects, or decisions, with optional shared-brain hub setup.
What letta-code runs automatically, and when. A hook is a command Claude Code fires at a fixed moment, without you asking for it.
Claude Code
$ npx -y skills add letta-ai/letta-code --agent claude-code
Cursor
Cursor also runs Claude Code hooks from .claude/settings.json, so copy these hooks there to use them in Cursor.
Where it lives
#!/bin/bash # Block dangerous rm -rf commands input=$(cat) tool_name=$(echo "$input" | jq -r '.tool_name') # Only check Bash commands if [ "$tool_name" != "Bash" ]; then exit 0 fi command=$(echo "$input" | jq -r '.tool_input.command') # Check for rm -rf pattern (handles -rf, -fr, -rfi, etc.) if echo "$command" | grep -qE 'rm\s+(-[a-zA-Z]*r[a-zA-Z]*f|-[a-zA-Z]*f[a-zA-Z]*r)'; then echo "Blocked: rm -rf commands must be ran manually, use rm and rmdir instead." >&2 exit 2 fi exit 0
#!/bin/bash # Send desktop notification using osascript (macOS) input=$(cat) message=$(echo "$input" | jq -r '.message') level=$(echo "$input" | jq -r '.level') # Display the notification (show subtitle only for warning/error) if [ "$level" = "error" ]; then osascript -e "display notification \"$message\" with title \"Letta Code\" subtitle \"Error\"" elif [ "$level" = "warning" ]; then osascript -e "display notification \"$message\" with title \"Letta Code\" subtitle \"Warning\"" else osascript -e "display notification \"$message\" with title \"Letta Code\"" fi exit 0
#!/bin/bash
# Hook script: Run bun run fix if there are uncommitted changes
# Triggered on: Stop event
# Check if there are any uncommitted changes (staged or unstaged)
if git diff --quiet HEAD 2>/dev/null; then
echo "No changes, skipping."
exit 0
fi
# Run fix - capture output and send to stderr on failure
output=$(bun run fix 2>&1)
exit_code=$?
if [ $exit_code -eq 0 ]; then
echo "$output"
exit 0
else
echo "$output" >&2
exit 2
fi
#!/usr/bin/env bun
// Helper script to get API key from keychain using Bun's secrets API
// Used by memory_logger.py to avoid separate keychain authorization
const SERVICE_NAME = "letta-code";
const API_KEY_NAME = "letta-api-key";
try {
const apiKey = await Bun.secrets.get({
service: SERVICE_NAME,
name: API_KEY_NAME,
});
if (apiKey) {
process.stdout.write(apiKey);
}
} catch {
// Silent failure - Python will try other sources
}
#!/usr/bin/env python3
"""
Memory Logger Hook - Tracks memory block changes with git-style diffs.
Structure:
.letta/memory_logs/
human.json # Current state from server
human.jsonl # Log of diffs (git-style patches)
persona.json
persona.jsonl
...
Hook: Fetches all memory blocks, compares to local state, logs diffs.
CLI:
list - Show all memory blocks
show <name> - Show current contents of a block
history <name> - Interactive diff navigation
"""
import json
import os
import re
import sys
import difflib
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
import urllib.request
import urllib.error
# =============================================================================
# Configuration
# =============================================================================
def get_logs_dir(working_dir: Optional[str] = None) -> Path:
"""Get the memory logs directory."""
if working_dir:
return Path(working_dir) / ".letta" / "memory_logs"
# For CLI usage, look relative to the script's parent directory (project root)
# since the script lives in /hooks/memory_logger.py
script_dir = Path(__file__).parent
project_root = script_dir.parent
return project_root / ".letta" / "memory_logs"
def get_letta_settings() -> dict:
"""Read Letta settings from ~/.letta/settings.json."""
settings_path = Path.home() / ".letta" / "settings.json"
if settings_path.exists():
try:
return json.loads(settings_path.read_text())
except (json.JSONDecodeError, IOError):
pass
return {}
def get_api_key_from_keychain() -> Optional[str]:
"""Get the Letta API key from macOS keychain via Bun helper."""
import subprocess
# Use Bun helper script (uses Bun's existing keychain access)
hooks_dir = Path(__file__).parent
helper_script = hooks_dir / "get-api-key.ts"
if helper_script.exists():
try:
result = subprocess.run(
["bun", str(helper_script)],
capture_output=True,
text=True,
timeout=5,
)
if result.returncode == 0 and result.stdout.strip():
return result.stdout.strip()
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
pass
return None
def get_api_key() -> Optional[str]:
"""Get the Letta API key from keychain, environment, or settings."""
# Try macOS keychain first
api_key = get_api_key_from_keychain()
if api_key:
return api_key
# Fall back to environment variable
api_key = os.environ.get("LETTA_API_KEY")
if api_key:
return api_key
# Fall back to settings file
settings = get_letta_settings()
env_settings = settings.get("env", {})
return env_settings.get("LETTA_API_KEY")
def get_base_url() -> str:
"""Get the Letta API base URL."""
base_url = os.environ.get("LETTA_BASE_URL")
if base_url:
return base_url.rstrip("/")
settings = get_letta_settings()
env_settings = settings.get("env", {})
return env_settings.get("LETTA_BASE_URL", "https://api.letta.com").rstrip("/")
# =============================================================================
# Letta API
# =============================================================================
def fetch_all_memory_blocks(agent_id: str, verbose: bool = False) -> list[dict]:
"""Fetch all memory blocks for an agent from the Letta API."""
api_key = get_api_key()
base_url = get_base_url()
if not api_key:
if verbose:
print(" ERROR: No API key available")
return []
url = f"{base_url}/v1/agents/{agent_id}/core-memory/blocks"
if verbose:
print(f" URL: {url}")
headers = {
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
}
try:
req = urllib.request.Request(url, headers=headers, method="GET")
with urllib.request.urlopen(req, timeout=10) as response:
data = json.loads(response.read().decode("utf-8"))
if verbose:
print(f" Response type: {type(data).__name__}")
return data if isinstance(data, list) else []
except urllib.error.HTTPError as e:
if verbose:
print(f" HTTP Error: {e.code} {e.reason}")
try:
body = e.read().decode("utf-8")
print(f" Response: {body[:200]}")
except Exception:
pass
return []
except (urllib.error.URLError, json.JSONDecodeError, TimeoutError) as e:
if verbose:
print(f" Error: {type(e).__name__}: {e}")
return []
except Exception as e:
if verbose:
print(f" Unexpected error: {type(e).__name__}: {e}")
return []
# =============================================================================
# Diff Operations
# =============================================================================
def create_unified_diff(old_content: str, new_content: str, block_name: str) -> str:
"""Create a unified diff between old and new content."""
old_lines = old_content.splitlines(keepends=True)
new_lines = new_content.splitlines(keepends=True)
# Ensure trailing newlines for proper diff
if old_lines and not old_lines[-1].endswith('\n'):
old_lines[-1] += '\n'
if new_lines and not new_lines[-1].endswith('\n'):
new_lines[-1] += '\n'
diff = difflib.unified_diff(
old_lines,
new_lines,
fromfile=f"a/{block_name}",
tofile=f"b/{block_name}",
)
return "".join(diff)
def apply_diff(content: str, diff_text: str, reverse: bool = False) -> str:
"""Apply or reverse a unified diff (pure Python implementation)."""
lines = content.splitlines()
diff_lines = diff_text.splitlines()
# Parse hunks from d#!/bin/bash
# Show current permission status when a permission request is made
input=$(cat)
tool_name=$(echo "$input" | jq -r '.tool_name')
working_dir=$(echo "$input" | jq -r '.working_directory')
# Colors for output
BOLD='\033[1m'
DIM='\033[2m'
GREEN='\033[32m'
RED='\033[31m'
YELLOW='\033[33m'
BLUE='\033[34m'
RESET='\033[0m'
echo -e "\n"
echo -e "${BOLD}═══════════════════════════════════════════════════════════════${RESET}"
echo -e "${BOLD}Permission Request: ${BLUE}$tool_name${RESET}"
echo -e "${BOLD}═══════════════════════════════════════════════════════════════${RESET}\n"
# Function to display permissions from a file
show_permissions() {
local file="$1"
local label="$2"
local color="$3"
echo -e "${color}${BOLD}$label${RESET}"
echo -e "${DIM}$file${RESET}"
if [ -f "$file" ]; then
local allow=$(jq -r '.permissions.allow // [] | .[]' "$file" 2>/dev/null)
local deny=$(jq -r '.permissions.deny // [] | .[]' "$file" 2>/dev/null)
local ask=$(jq -r '.permissions.ask // [] | .[]' "$file" 2>/dev/null)
if [ -n "$allow" ] || [ -n "$deny" ] || [ -n "$ask" ]; then
if [ -n "$allow" ]; then
echo -e " ${GREEN}Allow:${RESET}"
echo "$allow" | while read -r rule; do
[ -n "$rule" ] && echo -e " ${GREEN}✓${RESET} $rule"
done
fi
if [ -n "$deny" ]; then
echo -e " ${RED}Deny:${RESET}"
echo "$deny" | while read -r rule; do
[ -n "$rule" ] && echo -e " ${RED}✗${RESET} $rule"
done
fi
if [ -n "$ask" ]; then
echo -e " ${YELLOW}Ask:${RESET}"
echo "$ask" | while read -r rule; do
[ -n "$rule" ] && echo -e " ${YELLOW}?${RESET} $rule"
done
fi
else
echo -e " ${DIM}(none)${RESET}"
fi
else
echo -e " ${DIM}(file not found)${RESET}"
fi
echo ""
}
# XDG config settings (~/.config/letta/settings.json)
xdg_config="${XDG_CONFIG_HOME:-$HOME/.config}"
show_permissions "$xdg_config/letta/settings.json" "User Settings (XDG)" "$BLUE"
# Legacy global settings (~/.letta/settings.json)
show_permissions "$HOME/.letta/settings.json" "User Settings (Legacy)" "$BLUE"
# Project settings (.letta/settings.json)
show_permissions "$working_dir/.letta/settings.json" "Project Settings" "$YELLOW"
# Project local settings (.letta/settings.local.json)
show_permissions "$working_dir/.letta/settings.local.json" "Project Local Settings" "$GREEN"
echo -e "${BOLD}═══════════════════════════════════════════════════════════════${RESET}\n"
# Exit with code 1 to continue to normal permission flow (don't auto-allow/deny)
exit 1
All 8 scripts are listed above. The source is inlined for 6 of them, starting with whatever hooks.json actually runs. See all of them in the repo.
Read the script before you install anything that runs on your machine. This is the one part of a plugin that acts without being asked.
Letta Code is a stateful agent harness for creating agents that are more like people than tools. Letta Code agents have memory, identity, and a sense of experience over time.
Local-first memory for Claude Code. It stores conversations verbatim in a private palace you can search before answering about past work, people, projects, or decisions, with optional shared-brain hub setup.
Gives Claude Code a private memory that stays on your machine. It stores past conversations word for word, so you can ask what was decided, what happened before, or who was involved and get the original context back. It can also hand tasks to other agents.
Keeps large tool outputs, logs, and files out of Claude Code's context window by processing them in a sandbox and indexing them for focused search. Includes diagnostics and a knowledge base indexer.
Keeps large command outputs, logs and data files out of your AI assistant's context by running processing in a sandbox and returning only the relevant parts. You also get a searchable index of local files and diagnostics for checking the setup.
Gives coding agents a git-backed wiki for long-term project memory, with skills for retrieval, durable notes, session handoffs between agents, cross-project messaging, and memory consolidation.
A background agent for Claude Code that watches your sessions and reads your files, builds up memory over time, and offers guidance back during your work.
Audits a Claude Code or Codex setup for context-window waste, fixes it with the user's approval, and measures the savings. It also tracks costs, helps resume prior sessions, and offers a usage dashboard.
A self-learning system for Claude Code that captures your corrections during sessions and reminds you to run /reflect to save them to CLAUDE.md as permanent memory. It also discovers recurring workflow patterns.