/** * Git operations for git-backed agent memory. * * When memFS is enabled, the agent's memory is stored in a git repo * on the server at $LETTA_BASE_URL/v1/git/$AGENT_ID/state.git. * This module provides the CLI harness helpers: clone on first run, * pull on startup, and status check for system reminders. * * The agent itself handles commit/push via Bash tool calls. */ import { execFile as execFileCb } from "node:child_process"; import { chmodSync, existsSync, mkdirSync, renameSync, rmSync, writeFileSync, } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; import { promisify } from "node:util"; import { debugLog, debugWarn } from "../utils/debug"; import { getClient, getServerUrl } from "./client"; const execFile = promisify(execFileCb); export const GIT_MEMORY_ENABLED_TAG = "git-memory-enabled"; /** Get the agent root directory (~/.letta/agents/{id}/) */ export function getAgentRootDir(agentId: string): string { return join(homedir(), ".letta", "agents", agentId); } /** Get the git repo directory for memory (now ~/.letta/agents/{id}/memory/) */ export function getMemoryRepoDir(agentId: string): string { return join(getAgentRootDir(agentId), "memory"); } /** * Normalize a configured server URL for use in git credential config keys. * * Git credential config lookup is sensitive to URL key shape. We normalize to * origin form (scheme + host + optional port) and remove trailing slashes so * pull/push flows remain resilient when LETTA_BASE_URL has path/trailing-slash * variations. */ export function normalizeCredentialBaseUrl(serverUrl: string): string { const trimmed = serverUrl.trim().replace(/\/+$/, ""); try { const parsed = new URL(trimmed); return parsed.origin; } catch { // Fall back to a conservative slash-trimmed value if URL parsing fails. return trimmed; } } /** Git remote URL for the agent's state repo */ function getGitRemoteUrl(agentId: string): string { const baseUrl = getServerUrl().trim().replace(/\/+$/, ""); return `${baseUrl}/v1/git/${agentId}/state.git`; } /** * Get a fresh auth token for git operations. * Reuses the same token resolution flow as getClient() * (env var → settings → OAuth refresh). */ async function getAuthToken(): Promise { const client = await getClient(); // The client constructor resolves the token; extract it // biome-ignore lint/suspicious/noExplicitAny: accessing internal client options return (client as any)._options?.apiKey ?? ""; } /** * Run a git command in the given directory. * If a token is provided, passes it as an auth header. */ async function runGit( cwd: string, args: string[], token?: string, ): Promise<{ stdout: string; stderr: string }> { const authArgs = token ? [ "-c", `http.extraHeader=Authorization: Basic ${Buffer.from(`letta:${token}`).toString("base64")}`, ] : []; const allArgs = [...authArgs, ...args]; // Redact credential helper values to avoid leaking tokens in debug logs. const loggableArgs = args[0] === "config" && typeof args[1] === "string" && args[1].includes("credential") && args[1].includes(".helper") ? [args[0], args[1], ""] : args; debugLog("memfs-git", `git ${loggableArgs.join(" ")} (in ${cwd})`); const result = await execFile("git", allArgs, { cwd, maxBuffer: 10 * 1024 * 1024, // 10MB timeout: 60_000, // 60s }); return { stdout: result.stdout?.toString() ?? "", stderr: result.stderr?.toString() ?? "", }; } /** * Configure a local credential helper in the repo's .git/config * so plain `git push` / `git pull` work without auth prefixes. */ async function configureLocalCredentialHelper( dir: string, token: string, ): Promise { const rawBaseUrl = getServerUrl(); const normalizedBaseUrl = normalizeCredentialBaseUrl(rawBaseUrl); const helper = `!f() { echo "username=letta"; echo "password=${token}"; }; f`; // Primary config: normalized origin key (most robust for git's credential lookup) await runGit(dir, [ "config", `credential.${normalizedBaseUrl}.helper`, helper, ]); // Backcompat: also set raw configured URL key if it differs (older repos/configs) if (rawBaseUrl !== normalizedBaseUrl) { await runGit(dir, ["config", `credential.${rawBaseUrl}.helper`, helper]); } debugLog( "memfs-git", `Configured local credential helper for ${normalizedBaseUrl}${rawBaseUrl !== normalizedBaseUrl ? ` (and raw ${rawBaseUrl})` : ""}`, ); } /** * Bash pre-commit hook that validates frontmatter in memory .md files. * * Rules: * - Frontmatter is REQUIRED (must start with ---) * - Must be properly closed with --- * - Required fields: description (non-empty string), limit (positive integer) * - read_only is a PROTECTED field: agent cannot add, remove, or change it. * Files where HEAD has read_only: true cannot be modified at all. * - Only allowed agent-editable keys: description, limit * - read_only may exist (from server) but agent must not change it */ export const PRE_COMMIT_HOOK_SCRIPT = `#!/usr/bin/env bash # Validate frontmatter in staged memory .md files # Installed by Letta Code CLI AGENT_EDITABLE_KEYS="description limit" PROTECTED_KEYS="read_only" ALL_KNOWN_KEYS="description limit read_only" errors="" # Helper: extract a frontmatter value from content get_fm_value() { local content="$1" key="$2" local closing_line closing_line=$(echo "$content" | tail -n +2 | grep -n '^---$' | head -1 | cut -d: -f1) [ -z "$closing_line" ] && return echo "$content" | tail -n +2 | head -n $((closing_line - 1)) | grep "^$key:" | cut -d: -f2- | sed 's/^ *//;s/ *$//' } for file in $(git diff --cached --name-only --diff-filter=ACM | grep '^memory/.*\\.md$'); do staged=$(git show ":$file") # Frontmatter is required first_line=$(echo "$staged" | head -1) if [ "$first_line" != "---" ]; then errors="$errors\\n $file: missing frontmatter (must start with ---)" continue fi # Check frontmatter is properly closed closing_line=$(echo "$staged" | tail -n +2 | grep -n '^---$' | head -1 | cut -d: -f1) if [ -z "$closing_line" ]; then errors="$errors\\n $file: frontmatter opened but never closed (missing closing ---)" continue fi # Check read_only protection against HEAD version head_content=$(git show "HEAD:$file" 2>/dev/null || true) if [ -n "$head_content" ]; then head_ro=$(get_fm_value "$head_content" "read_only") if [ "$head_ro" = "true" ]; then errors="$errors\\n $file: file is read_only and cannot be modified" continue fi fi # Extract frontmatter lines frontmatter=$(echo "$staged" | tail -n +2 | head -n $((closing_line - 1))) # Track required fields has_description=false has_limit=false # Validate each line while IFS= read -r line; do [ -z "$line" ] && continue key=$(echo "$line" | cut -d: -f1 | tr -d ' ') value=$(echo "$line" | cut -d: -f2- | sed 's/^ *//;s/ *$//') # Check key is known known=false for k in $ALL_KNOWN_KEYS; do if [ "$key" = "$k" ]; then known=true break fi done if [ "$known" = "false" ]; then errors="$errors\\n $file: unknown frontmatter key '$key' (allowed: $ALL_KNOWN_KEYS)" continue fi # Check if agent is trying to modify a protected key for k in $PROTECTED_KEYS; do if [ "$key" = "$k" ]; then # Compare against HEAD — if value changed (or key was added), reject if [ -n "$head_content" ]; then head_val=$(get_fm_value "$head_content" "$key") if [ "$value" != "$head_val" ]; then errors="$errors\\n $file: '$key' is a protected field and cannot be changed by the agent" fi else # New file with read_only — agent shouldn't set this errors="$errors\\n $file: '$key' is a protected field and cannot be set by the agent" fi fi done # Validate value types case "$key" in limit) has_limit=true if ! echo "$value" | grep -qE '^[0-9]+$' || [ "$value" = "0" ]; then errors="$errors\\n $file: 'limit' must be a positive integer, got '$value'" fi ;; description) has_description=true if [ -z "$value" ]; then errors="$errors\\n $file: 'description' must not be empty" fi ;; esac done <<< "$frontmatter" # Check required fields if [ "$has_description" = "false" ]; then errors="$errors\\n $file: missing required field 'description'" fi if [ "$has_limit" = "false" ]; then errors="$errors\\n $file: missing required field 'limit'" fi # Check if protected keys were removed (existed in HEAD but not in staged) if [ -n "$head_content" ]; then for k in $PROTECTED_KEYS; do head_val=$(get_fm_value "$head_content" "$k") if [ -n "$head_val" ]; then staged_val=$(get_fm_value "$staged" "$k") if [ -z "$staged_val" ]; then errors="$errors\\n $file: '$k' is a protected field and cannot be removed by the agent" fi fi done fi done if [ -n "$errors" ]; then echo "Frontmatter validation failed:" echo -e "$errors" exit 1 fi `; /** * Install the pre-commit hook for frontmatter validation. */ function installPreCommitHook(dir: string): void { const hooksDir = join(dir, ".git", "hooks"); const hookPath = join(hooksDir, "pre-commit"); if (!existsSync(hooksDir)) { mkdirSync(hooksDir, { recursive: true }); } writeFileSync(hookPath, PRE_COMMIT_HOOK_SCRIPT, "utf-8"); chmodSync(hookPath, 0o755); debugLog("memfs-git", "Installed pre-commit hook"); } /** Check if the memory directory is a git repo */ export function isGitRepo(agentId: string): boolean { return existsSync(join(getMemoryRepoDir(agentId), ".git")); } /** * Clone the agent's state repo into the memory directory. * * Git root is ~/.letta/agents/{id}/memory/ (not the agent root). */ export async function cloneMemoryRepo(agentId: string): Promise { const token = await getAuthToken(); const url = getGitRemoteUrl(agentId); const dir = getMemoryRepoDir(agentId); debugLog("memfs-git", `Cloning ${url} → ${dir}`); if (!existsSync(dir)) { // Fresh clone into new memory directory mkdirSync(dir, { recursive: true }); await runGit(dir, ["clone", url, "."], token); } else if (!existsSync(join(dir, ".git"))) { // Directory exists but isn't a git repo (legacy local layout) // Clone to temp, move .git/ into existing dir, then checkout files. const tmpDir = `${dir}-git-clone-tmp`; try { if (existsSync(tmpDir)) { rmSync(tmpDir, { recursive: true, force: true }); } mkdirSync(tmpDir, { recursive: true }); await runGit(tmpDir, ["clone", url, "."], token); // Move .git into the existing memory directory renameSync(join(tmpDir, ".git"), join(dir, ".git")); // Reset to match remote state await runGit(dir, ["checkout", "--", "."], token); debugLog("memfs-git", "Migrated existing memory directory to git repo"); } finally { if (existsSync(tmpDir)) { rmSync(tmpDir, { recursive: true, force: true }); } } } // Configure local credential helper so the agent can do plain // `git push` / `git pull` without auth prefixes. await configureLocalCredentialHelper(dir, token); // Install pre-commit hook to validate frontmatter installPreCommitHook(dir); } /** * Pull latest changes from the server. * Called on startup to ensure local state is current. */ export async function pullMemory( agentId: string, ): Promise<{ updated: boolean; summary: string }> { const token = await getAuthToken(); const dir = getMemoryRepoDir(agentId); // Self-healing: ensure credential helper and pre-commit hook are configured await configureLocalCredentialHelper(dir, token); installPreCommitHook(dir); try { const { stdout, stderr } = await runGit(dir, ["pull", "--ff-only"], token); const output = stdout + stderr; const updated = !output.includes("Already up to date"); return { updated, summary: updated ? output.trim() : "Already up to date", }; } catch { // If ff-only fails (diverged), try rebase debugWarn("memfs-git", "Fast-forward pull failed, trying rebase"); try { const { stdout, stderr } = await runGit(dir, ["pull", "--rebase"], token); return { updated: true, summary: (stdout + stderr).trim() }; } catch (rebaseErr) { const msg = rebaseErr instanceof Error ? rebaseErr.message : String(rebaseErr); debugWarn("memfs-git", `Pull failed: ${msg}`); return { updated: false, summary: `Pull failed: ${msg}\nHint: verify remote and auth:\n- git -C ${dir} remote -v\n- git -C ${dir} config --get-regexp '^credential\\..*\\.helper$'`, }; } } } export interface MemoryGitStatus { /** Uncommitted changes in working tree */ dirty: boolean; /** Local commits not pushed to remote */ aheadOfRemote: boolean; /** Human-readable summary for system reminder */ summary: string; } /** * Check git status of the memory directory. * Used to decide whether to inject a sync reminder. */ export async function getMemoryGitStatus( agentId: string, ): Promise { const dir = getMemoryRepoDir(agentId); // Check for uncommitted changes const { stdout: statusOut } = await runGit(dir, ["status", "--porcelain"]); const dirty = statusOut.trim().length > 0; // Check if local is ahead of remote let aheadOfRemote = false; try { const { stdout: revListOut } = await runGit(dir, [ "rev-list", "--count", "@{u}..HEAD", ]); const aheadCount = parseInt(revListOut.trim(), 10); aheadOfRemote = aheadCount > 0; } catch { // No upstream configured or other error - ignore } // Build summary const parts: string[] = []; if (dirty) { const changedFiles = statusOut .trim() .split("\n") .filter((l) => l.trim()) .map((l) => l.trim()); parts.push(`${changedFiles.length} uncommitted change(s)`); } if (aheadOfRemote) { parts.push("local commits not pushed to remote"); } return { dirty, aheadOfRemote, summary: parts.length > 0 ? parts.join(", ") : "clean", }; } /** * Add the git-memory-enabled tag to an agent. * This triggers the backend to create the git repo. */ export async function addGitMemoryTag( agentId: string, prefetchedAgent?: { tags?: string[] | null }, ): Promise { const client = await getClient(); try { const agent = prefetchedAgent ?? (await client.agents.retrieve(agentId)); const tags = agent.tags || []; if (!tags.includes(GIT_MEMORY_ENABLED_TAG)) { await client.agents.update(agentId, { tags: [...tags, GIT_MEMORY_ENABLED_TAG], }); debugLog("memfs-git", `Added ${GIT_MEMORY_ENABLED_TAG} tag`); } } catch (err) { debugWarn( "memfs-git", `Failed to add git-memory tag: ${err instanceof Error ? err.message : String(err)}`, ); } } /** * Remove the git-memory-enabled tag from an agent. */ export async function removeGitMemoryTag(agentId: string): Promise { const client = await getClient(); try { const agent = await client.agents.retrieve(agentId); const tags = agent.tags || []; if (tags.includes(GIT_MEMORY_ENABLED_TAG)) { await client.agents.update(agentId, { tags: tags.filter((t) => t !== GIT_MEMORY_ENABLED_TAG), }); debugLog("memfs-git", `Removed ${GIT_MEMORY_ENABLED_TAG} tag`); } } catch (err) { debugWarn( "memfs-git", `Failed to remove git-memory tag: ${err instanceof Error ? err.message : String(err)}`, ); } }