Files
letta-code/src/agent/memoryGit.ts
2026-02-27 10:52:05 -08:00

507 lines
16 KiB
TypeScript

/**
* 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<string> {
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], "<redacted>"]
: 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<void> {
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<void> {
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<MemoryGitStatus> {
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<void> {
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<void> {
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)}`,
);
}
}