Files
letta-code/src/agent/memoryFilesystem.ts

311 lines
9.2 KiB
TypeScript

/**
* Memory filesystem helpers.
*
* With git-backed memory, most sync/hash logic is removed.
* This module retains: directory helpers, tree rendering, and
* the shared memfs initialization logic used by both interactive
* and headless code paths.
*/
import { existsSync, mkdirSync } from "node:fs";
import { homedir } from "node:os";
import { join } from "node:path";
export const MEMORY_FS_ROOT = ".letta";
export const MEMORY_FS_AGENTS_DIR = "agents";
export const MEMORY_FS_MEMORY_DIR = "memory";
export const MEMORY_SYSTEM_DIR = "system";
// ----- Directory helpers -----
export function getMemoryFilesystemRoot(
agentId: string,
homeDir: string = homedir(),
): string {
return join(
homeDir,
MEMORY_FS_ROOT,
MEMORY_FS_AGENTS_DIR,
agentId,
MEMORY_FS_MEMORY_DIR,
);
}
export function getMemorySystemDir(
agentId: string,
homeDir: string = homedir(),
): string {
return join(getMemoryFilesystemRoot(agentId, homeDir), MEMORY_SYSTEM_DIR);
}
export function ensureMemoryFilesystemDirs(
agentId: string,
homeDir: string = homedir(),
): void {
const root = getMemoryFilesystemRoot(agentId, homeDir);
const systemDir = getMemorySystemDir(agentId, homeDir);
if (!existsSync(root)) {
mkdirSync(root, { recursive: true });
}
if (!existsSync(systemDir)) {
mkdirSync(systemDir, { recursive: true });
}
}
// ----- Path helpers -----
export function labelFromRelativePath(relativePath: string): string {
const normalized = relativePath.replace(/\\/g, "/");
return normalized.replace(/\.md$/, "");
}
// ----- Tree rendering -----
/**
* Render a tree visualization of the memory filesystem.
* Takes system labels (under system/) and detached labels (at root).
*/
export function renderMemoryFilesystemTree(
systemLabels: string[],
detachedLabels: string[],
): string {
type TreeNode = { children: Map<string, TreeNode>; isFile: boolean };
const makeNode = (): TreeNode => ({ children: new Map(), isFile: false });
const root = makeNode();
const insertPath = (base: string | null, label: string) => {
const parts = base ? [base, ...label.split("/")] : label.split("/");
let current = root;
for (const [i, partName] of parts.entries()) {
const part = i === parts.length - 1 ? `${partName}.md` : partName;
if (!current.children.has(part)) {
current.children.set(part, makeNode());
}
current = current.children.get(part) as TreeNode;
if (i === parts.length - 1) {
current.isFile = true;
}
}
};
for (const label of systemLabels) {
insertPath(MEMORY_SYSTEM_DIR, label);
}
for (const label of detachedLabels) {
insertPath(null, label);
}
// Always show system/ directory even if empty
if (!root.children.has(MEMORY_SYSTEM_DIR)) {
root.children.set(MEMORY_SYSTEM_DIR, makeNode());
}
const sortedEntries = (node: TreeNode) => {
const entries = Array.from(node.children.entries());
return entries.sort(([nameA, nodeA], [nameB, nodeB]) => {
if (nodeA.isFile !== nodeB.isFile) {
return nodeA.isFile ? 1 : -1;
}
return nameA.localeCompare(nameB);
});
};
const lines: string[] = ["/memory/"];
const render = (node: TreeNode, prefix: string) => {
const entries = sortedEntries(node);
entries.forEach(([name, child], index) => {
const isLast = index === entries.length - 1;
const branch = isLast ? "└──" : "├──";
lines.push(`${prefix}${branch} ${name}${child.isFile ? "" : "/"}`);
if (child.children.size > 0) {
const nextPrefix = `${prefix}${isLast ? " " : "│ "}`;
render(child, nextPrefix);
}
});
};
render(root, "");
return lines.join("\n");
}
// ----- Shared memfs initialization -----
export interface ApplyMemfsFlagsResult {
/** Whether memfs was enabled, disabled, or unchanged */
action: "enabled" | "disabled" | "unchanged";
/** Path to the memory directory (when enabled) */
memoryDir?: string;
/** Summary from git pull (when pullOnExistingRepo is true and repo already existed) */
pullSummary?: string;
}
export interface ApplyMemfsFlagsOptions {
pullOnExistingRepo?: boolean;
agentTags?: string[];
/** Skip the system prompt update (when the agent was created with the correct mode). */
skipPromptUpdate?: boolean;
}
/**
* Apply --memfs / --no-memfs CLI flags (or /memfs enable) to an agent.
*
* Shared between interactive (index.ts), headless (headless.ts), and
* the /memfs enable command (App.tsx) to avoid duplicating the setup logic.
*
* Steps when toggling:
* 1. Validate Letta Cloud requirement (for explicit enable)
* 2. Reconcile system prompt to the target memory mode
* 3. Persist memfs setting locally
* 4. Detach old API-based memory tools (when enabling)
* 5. Add git-memory-enabled tag + clone/pull repo
*
* @throws {Error} if Letta Cloud validation fails or git setup fails
*/
export async function applyMemfsFlags(
agentId: string,
memfsFlag: boolean | undefined,
noMemfsFlag: boolean | undefined,
options?: ApplyMemfsFlagsOptions,
): Promise<ApplyMemfsFlagsResult> {
const { settingsManager } = await import("../settings-manager");
// Validate explicit enable on supported backend.
if (memfsFlag && !(await isLettaCloud())) {
throw new Error(
"--memfs is only available on Letta Cloud (api.letta.com).",
);
}
const hasExplicitToggle = Boolean(memfsFlag || noMemfsFlag);
const localMemfsEnabled = settingsManager.isMemfsEnabled(agentId);
const { GIT_MEMORY_ENABLED_TAG } = await import("./memoryGit");
const shouldAutoEnableFromTag =
!hasExplicitToggle &&
!localMemfsEnabled &&
Boolean(options?.agentTags?.includes(GIT_MEMORY_ENABLED_TAG));
const targetEnabled = memfsFlag
? true
: noMemfsFlag
? false
: shouldAutoEnableFromTag
? true
: localMemfsEnabled;
// 2. Reconcile system prompt first, then persist local memfs setting.
if (hasExplicitToggle || shouldAutoEnableFromTag) {
if (!options?.skipPromptUpdate) {
const { updateAgentSystemPromptMemfs } = await import("./modify");
const promptUpdate = await updateAgentSystemPromptMemfs(
agentId,
targetEnabled,
);
if (!promptUpdate.success) {
throw new Error(promptUpdate.message);
}
}
settingsManager.setMemfsEnabled(agentId, targetEnabled);
}
const isEnabled =
hasExplicitToggle || shouldAutoEnableFromTag
? targetEnabled
: settingsManager.isMemfsEnabled(agentId);
// 3. Detach old API-based memory tools when enabling.
if (isEnabled && (memfsFlag || shouldAutoEnableFromTag)) {
const { detachMemoryTools } = await import("../tools/toolset");
await detachMemoryTools(agentId);
// Migration (LET-7353): Remove legacy skills/loaded_skills blocks.
// These blocks are no longer used — skills are now injected via system reminders.
const { getClient } = await import("./client");
const client = await getClient();
for (const label of ["skills", "loaded_skills"]) {
try {
const block = await client.agents.blocks.retrieve(label, {
agent_id: agentId,
});
if (block) {
await client.agents.blocks.detach(block.id, {
agent_id: agentId,
});
await client.blocks.delete(block.id);
}
} catch {
// Block doesn't exist or already removed, skip
}
}
}
// Keep server-side state aligned with explicit disable.
if (noMemfsFlag) {
const { removeGitMemoryTag } = await import("./memoryGit");
await removeGitMemoryTag(agentId);
}
// 4. Add git tag + clone/pull repo.
let pullSummary: string | undefined;
if (isEnabled) {
const { addGitMemoryTag, isGitRepo, cloneMemoryRepo, pullMemory } =
await import("./memoryGit");
await addGitMemoryTag(
agentId,
options?.agentTags ? { tags: options.agentTags } : undefined,
);
if (!isGitRepo(agentId)) {
await cloneMemoryRepo(agentId);
} else if (options?.pullOnExistingRepo) {
const result = await pullMemory(agentId);
pullSummary = result.summary;
}
}
const action =
memfsFlag || shouldAutoEnableFromTag
? "enabled"
: noMemfsFlag
? "disabled"
: "unchanged";
return {
action,
memoryDir: isEnabled ? getMemoryFilesystemRoot(agentId) : undefined,
pullSummary,
};
}
/**
* Whether the current server is Letta Cloud (or local memfs testing is enabled).
*/
export async function isLettaCloud(): Promise<boolean> {
const { getServerUrl } = await import("./client");
const serverUrl = getServerUrl();
return (
serverUrl.includes("api.letta.com") || process.env.LETTA_MEMFS_LOCAL === "1"
);
}
/**
* Enable memfs for a newly created agent if on Letta Cloud.
* Non-fatal: logs a warning on failure. Skips on self-hosted.
*
* Skips the system prompt update since callers are expected to create
* the agent with the correct memory mode upfront.
*/
export async function enableMemfsIfCloud(agentId: string): Promise<void> {
if (!(await isLettaCloud())) return;
try {
await applyMemfsFlags(agentId, true, undefined, {
skipPromptUpdate: true,
});
} catch (error) {
console.warn(
`Warning: Could not enable memfs for new agent: ${error instanceof Error ? error.message : String(error)}`,
);
}
}