feat: sync memory with filesystem tree (#685)
Co-authored-by: Letta <noreply@letta.com> Co-authored-by: cpacker <packercharles@gmail.com>
This commit is contained in:
@@ -15,9 +15,9 @@ export const GLOBAL_BLOCK_LABELS = ["persona", "human"] as const;
|
||||
* Block labels that are stored per-project (local to the current directory).
|
||||
*/
|
||||
export const PROJECT_BLOCK_LABELS = [
|
||||
"project",
|
||||
"skills",
|
||||
"loaded_skills",
|
||||
"memory_filesystem",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
@@ -37,7 +37,11 @@ export type MemoryBlockLabel = (typeof MEMORY_BLOCK_LABELS)[number];
|
||||
* Block labels that should be read-only (agent cannot modify via memory tools).
|
||||
* These blocks are managed by specific tools (e.g., Skill tool for skills/loaded_skills).
|
||||
*/
|
||||
export const READ_ONLY_BLOCK_LABELS = ["skills", "loaded_skills"] as const;
|
||||
export const READ_ONLY_BLOCK_LABELS = [
|
||||
"skills",
|
||||
"loaded_skills",
|
||||
"memory_filesystem",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Block labels that should be isolated per-conversation.
|
||||
|
||||
753
src/agent/memoryFilesystem.ts
Normal file
753
src/agent/memoryFilesystem.ts
Normal file
@@ -0,0 +1,753 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { existsSync, mkdirSync, readFileSync } from "node:fs";
|
||||
import { readdir, readFile, unlink, writeFile } from "node:fs/promises";
|
||||
import { homedir } from "node:os";
|
||||
import { dirname, join, relative } from "node:path";
|
||||
|
||||
import type { Block } from "@letta-ai/letta-client/resources/agents/blocks";
|
||||
import { getClient } from "./client";
|
||||
|
||||
export const MEMORY_FILESYSTEM_BLOCK_LABEL = "memory_filesystem";
|
||||
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";
|
||||
export const MEMORY_USER_DIR = "user";
|
||||
export const MEMORY_FS_STATE_FILE = ".sync-state.json";
|
||||
|
||||
const MANAGED_BLOCK_LABELS = new Set([MEMORY_FILESYSTEM_BLOCK_LABEL]);
|
||||
|
||||
type SyncState = {
|
||||
systemBlocks: Record<string, string>;
|
||||
systemFiles: Record<string, string>;
|
||||
userBlocks: Record<string, string>;
|
||||
userFiles: Record<string, string>;
|
||||
userBlockIds: Record<string, string>;
|
||||
lastSync: string | null;
|
||||
};
|
||||
|
||||
export type MemorySyncConflict = {
|
||||
label: string;
|
||||
blockValue: string | null;
|
||||
fileValue: string | null;
|
||||
};
|
||||
|
||||
export type MemorySyncResult = {
|
||||
updatedBlocks: string[];
|
||||
createdBlocks: string[];
|
||||
deletedBlocks: string[];
|
||||
updatedFiles: string[];
|
||||
createdFiles: string[];
|
||||
deletedFiles: string[];
|
||||
conflicts: MemorySyncConflict[];
|
||||
};
|
||||
|
||||
export type MemorySyncResolution = {
|
||||
label: string;
|
||||
resolution: "file" | "block";
|
||||
};
|
||||
|
||||
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 getMemoryUserDir(
|
||||
agentId: string,
|
||||
homeDir: string = homedir(),
|
||||
): string {
|
||||
return join(getMemoryFilesystemRoot(agentId, homeDir), MEMORY_USER_DIR);
|
||||
}
|
||||
|
||||
function getMemoryStatePath(
|
||||
agentId: string,
|
||||
homeDir: string = homedir(),
|
||||
): string {
|
||||
return join(getMemoryFilesystemRoot(agentId, homeDir), MEMORY_FS_STATE_FILE);
|
||||
}
|
||||
|
||||
export function ensureMemoryFilesystemDirs(
|
||||
agentId: string,
|
||||
homeDir: string = homedir(),
|
||||
): void {
|
||||
const root = getMemoryFilesystemRoot(agentId, homeDir);
|
||||
const systemDir = getMemorySystemDir(agentId, homeDir);
|
||||
const userDir = getMemoryUserDir(agentId, homeDir);
|
||||
|
||||
if (!existsSync(root)) {
|
||||
mkdirSync(root, { recursive: true });
|
||||
}
|
||||
if (!existsSync(systemDir)) {
|
||||
mkdirSync(systemDir, { recursive: true });
|
||||
}
|
||||
if (!existsSync(userDir)) {
|
||||
mkdirSync(userDir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
function hashContent(content: string): string {
|
||||
return createHash("sha256").update(content).digest("hex");
|
||||
}
|
||||
|
||||
function loadSyncState(
|
||||
agentId: string,
|
||||
homeDir: string = homedir(),
|
||||
): SyncState {
|
||||
const statePath = getMemoryStatePath(agentId, homeDir);
|
||||
if (!existsSync(statePath)) {
|
||||
return {
|
||||
systemBlocks: {},
|
||||
systemFiles: {},
|
||||
userBlocks: {},
|
||||
userFiles: {},
|
||||
userBlockIds: {},
|
||||
lastSync: null,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = readFileSync(statePath, "utf-8");
|
||||
const parsed = JSON.parse(raw) as Partial<SyncState> & {
|
||||
blocks?: Record<string, string>;
|
||||
files?: Record<string, string>;
|
||||
};
|
||||
return {
|
||||
systemBlocks: parsed.systemBlocks || parsed.blocks || {},
|
||||
systemFiles: parsed.systemFiles || parsed.files || {},
|
||||
userBlocks: parsed.userBlocks || {},
|
||||
userFiles: parsed.userFiles || {},
|
||||
userBlockIds: parsed.userBlockIds || {},
|
||||
lastSync: parsed.lastSync || null,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
systemBlocks: {},
|
||||
systemFiles: {},
|
||||
userBlocks: {},
|
||||
userFiles: {},
|
||||
userBlockIds: {},
|
||||
lastSync: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSyncState(
|
||||
state: SyncState,
|
||||
agentId: string,
|
||||
homeDir: string = homedir(),
|
||||
) {
|
||||
const statePath = getMemoryStatePath(agentId, homeDir);
|
||||
await writeFile(statePath, JSON.stringify(state, null, 2));
|
||||
}
|
||||
|
||||
async function scanMdFiles(dir: string, baseDir = dir): Promise<string[]> {
|
||||
const entries = await readdir(dir, { withFileTypes: true });
|
||||
const results: string[] = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
results.push(...(await scanMdFiles(fullPath, baseDir)));
|
||||
} else if (entry.isFile() && entry.name.endsWith(".md")) {
|
||||
results.push(relative(baseDir, fullPath));
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
function labelFromRelativePath(relativePath: string): string {
|
||||
const normalized = relativePath.replace(/\\/g, "/");
|
||||
return normalized.replace(/\.md$/, "");
|
||||
}
|
||||
|
||||
async function readMemoryFiles(
|
||||
dir: string,
|
||||
): Promise<Map<string, { content: string; path: string }>> {
|
||||
const files = await scanMdFiles(dir);
|
||||
const entries = new Map<string, { content: string; path: string }>();
|
||||
|
||||
for (const relativePath of files) {
|
||||
const label = labelFromRelativePath(relativePath);
|
||||
const fullPath = join(dir, relativePath);
|
||||
const content = await readFile(fullPath, "utf-8");
|
||||
entries.set(label, { content, path: fullPath });
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
async function ensureFilePath(filePath: string) {
|
||||
const parent = dirname(filePath);
|
||||
if (!existsSync(parent)) {
|
||||
mkdirSync(parent, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function writeMemoryFile(dir: string, label: string, content: string) {
|
||||
const filePath = join(dir, `${label}.md`);
|
||||
await ensureFilePath(filePath);
|
||||
await writeFile(filePath, content, "utf-8");
|
||||
}
|
||||
|
||||
async function deleteMemoryFile(dir: string, label: string) {
|
||||
const filePath = join(dir, `${label}.md`);
|
||||
if (existsSync(filePath)) {
|
||||
await unlink(filePath);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchAgentBlocks(agentId: string): Promise<Block[]> {
|
||||
const client = await getClient();
|
||||
const blocksResponse = await client.agents.blocks.list(agentId);
|
||||
const blocks = Array.isArray(blocksResponse)
|
||||
? blocksResponse
|
||||
: (blocksResponse as { items?: Block[] }).items ||
|
||||
(blocksResponse as { blocks?: Block[] }).blocks ||
|
||||
[];
|
||||
|
||||
return blocks;
|
||||
}
|
||||
|
||||
export function renderMemoryFilesystemTree(
|
||||
systemLabels: string[],
|
||||
userLabels: 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, label: string) => {
|
||||
const parts = [base, ...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 userLabels) {
|
||||
insertPath(MEMORY_USER_DIR, label);
|
||||
}
|
||||
|
||||
if (!root.children.has(MEMORY_SYSTEM_DIR)) {
|
||||
root.children.set(MEMORY_SYSTEM_DIR, makeNode());
|
||||
}
|
||||
if (!root.children.has(MEMORY_USER_DIR)) {
|
||||
root.children.set(MEMORY_USER_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");
|
||||
}
|
||||
|
||||
function buildStateHashes(
|
||||
systemBlocks: Map<string, { value: string }>,
|
||||
systemFiles: Map<string, { content: string }>,
|
||||
userBlocks: Map<string, { value: string }>,
|
||||
userFiles: Map<string, { content: string }>,
|
||||
userBlockIds: Record<string, string>,
|
||||
): SyncState {
|
||||
const systemBlockHashes: Record<string, string> = {};
|
||||
const systemFileHashes: Record<string, string> = {};
|
||||
const userBlockHashes: Record<string, string> = {};
|
||||
const userFileHashes: Record<string, string> = {};
|
||||
|
||||
systemBlocks.forEach((block, label) => {
|
||||
systemBlockHashes[label] = hashContent(block.value || "");
|
||||
});
|
||||
|
||||
systemFiles.forEach((file, label) => {
|
||||
systemFileHashes[label] = hashContent(file.content || "");
|
||||
});
|
||||
|
||||
userBlocks.forEach((block, label) => {
|
||||
userBlockHashes[label] = hashContent(block.value || "");
|
||||
});
|
||||
|
||||
userFiles.forEach((file, label) => {
|
||||
userFileHashes[label] = hashContent(file.content || "");
|
||||
});
|
||||
|
||||
return {
|
||||
systemBlocks: systemBlockHashes,
|
||||
systemFiles: systemFileHashes,
|
||||
userBlocks: userBlockHashes,
|
||||
userFiles: userFileHashes,
|
||||
userBlockIds,
|
||||
lastSync: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export async function syncMemoryFilesystem(
|
||||
agentId: string,
|
||||
options: { homeDir?: string; resolutions?: MemorySyncResolution[] } = {},
|
||||
): Promise<MemorySyncResult> {
|
||||
const homeDir = options.homeDir ?? homedir();
|
||||
ensureMemoryFilesystemDirs(agentId, homeDir);
|
||||
|
||||
const systemDir = getMemorySystemDir(agentId, homeDir);
|
||||
const userDir = getMemoryUserDir(agentId, homeDir);
|
||||
const systemFiles = await readMemoryFiles(systemDir);
|
||||
const userFiles = await readMemoryFiles(userDir);
|
||||
systemFiles.delete(MEMORY_FILESYSTEM_BLOCK_LABEL);
|
||||
|
||||
const attachedBlocks = await fetchAgentBlocks(agentId);
|
||||
const systemBlockMap = new Map(
|
||||
attachedBlocks
|
||||
.filter((block) => block.label)
|
||||
.map((block) => [block.label as string, block]),
|
||||
);
|
||||
systemBlockMap.delete(MEMORY_FILESYSTEM_BLOCK_LABEL);
|
||||
|
||||
const lastState = loadSyncState(agentId, homeDir);
|
||||
const conflicts: MemorySyncConflict[] = [];
|
||||
|
||||
const updatedBlocks: string[] = [];
|
||||
const createdBlocks: string[] = [];
|
||||
const deletedBlocks: string[] = [];
|
||||
const updatedFiles: string[] = [];
|
||||
const createdFiles: string[] = [];
|
||||
const deletedFiles: string[] = [];
|
||||
|
||||
const resolutions = new Map(
|
||||
(options.resolutions ?? []).map((resolution) => [
|
||||
resolution.label,
|
||||
resolution,
|
||||
]),
|
||||
);
|
||||
|
||||
const client = await getClient();
|
||||
|
||||
const userBlockIds = { ...lastState.userBlockIds };
|
||||
const userBlockMap = new Map<string, Block>();
|
||||
for (const [label, blockId] of Object.entries(userBlockIds)) {
|
||||
try {
|
||||
const block = await client.blocks.retrieve(blockId);
|
||||
userBlockMap.set(label, block as Block);
|
||||
} catch {
|
||||
delete userBlockIds[label];
|
||||
}
|
||||
}
|
||||
|
||||
const systemLabels = new Set<string>([
|
||||
...Array.from(systemFiles.keys()),
|
||||
...Array.from(systemBlockMap.keys()),
|
||||
...Object.keys(lastState.systemBlocks),
|
||||
...Object.keys(lastState.systemFiles),
|
||||
]);
|
||||
|
||||
for (const label of Array.from(systemLabels).sort()) {
|
||||
if (MANAGED_BLOCK_LABELS.has(label)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const fileEntry = systemFiles.get(label);
|
||||
const blockEntry = systemBlockMap.get(label);
|
||||
|
||||
const fileHash = fileEntry ? hashContent(fileEntry.content) : null;
|
||||
const blockHash = blockEntry ? hashContent(blockEntry.value || "") : null;
|
||||
|
||||
const lastFileHash = lastState.systemFiles[label] || null;
|
||||
const lastBlockHash = lastState.systemBlocks[label] || null;
|
||||
|
||||
const fileChanged = fileHash !== lastFileHash;
|
||||
const blockChanged = blockHash !== lastBlockHash;
|
||||
|
||||
const resolution = resolutions.get(label);
|
||||
|
||||
if (fileEntry && !blockEntry) {
|
||||
if (lastBlockHash && !fileChanged) {
|
||||
// Block was deleted elsewhere; delete file.
|
||||
await deleteMemoryFile(systemDir, label);
|
||||
deletedFiles.push(label);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Create block from file
|
||||
const createdBlock = await client.blocks.create({
|
||||
label,
|
||||
value: fileEntry.content,
|
||||
description: `Memory block: ${label}`,
|
||||
limit: 20000,
|
||||
});
|
||||
if (createdBlock.id) {
|
||||
await client.agents.blocks.attach(createdBlock.id, {
|
||||
agent_id: agentId,
|
||||
});
|
||||
}
|
||||
createdBlocks.push(label);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!fileEntry && blockEntry) {
|
||||
if (lastFileHash && !blockChanged) {
|
||||
// File deleted, block unchanged -> delete block
|
||||
if (blockEntry.id) {
|
||||
await client.agents.blocks.detach(blockEntry.id, {
|
||||
agent_id: agentId,
|
||||
});
|
||||
}
|
||||
deletedBlocks.push(label);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Create file from block
|
||||
await writeMemoryFile(systemDir, label, blockEntry.value || "");
|
||||
createdFiles.push(label);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!fileEntry || !blockEntry) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (fileChanged && blockChanged && !resolution) {
|
||||
conflicts.push({
|
||||
label,
|
||||
blockValue: blockEntry.value || "",
|
||||
fileValue: fileEntry.content,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (resolution?.resolution === "file") {
|
||||
await client.agents.blocks.update(label, {
|
||||
agent_id: agentId,
|
||||
value: fileEntry.content,
|
||||
});
|
||||
updatedBlocks.push(label);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (resolution?.resolution === "block") {
|
||||
await writeMemoryFile(systemDir, label, blockEntry.value || "");
|
||||
updatedFiles.push(label);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (fileChanged && !blockChanged) {
|
||||
await client.agents.blocks.update(label, {
|
||||
agent_id: agentId,
|
||||
value: fileEntry.content,
|
||||
});
|
||||
updatedBlocks.push(label);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!fileChanged && blockChanged) {
|
||||
await writeMemoryFile(systemDir, label, blockEntry.value || "");
|
||||
updatedFiles.push(label);
|
||||
}
|
||||
}
|
||||
|
||||
const userLabels = new Set<string>([
|
||||
...Array.from(userFiles.keys()),
|
||||
...Array.from(userBlockMap.keys()),
|
||||
...Object.keys(lastState.userBlocks),
|
||||
...Object.keys(lastState.userFiles),
|
||||
]);
|
||||
|
||||
for (const label of Array.from(userLabels).sort()) {
|
||||
const fileEntry = userFiles.get(label);
|
||||
const blockEntry = userBlockMap.get(label);
|
||||
|
||||
const fileHash = fileEntry ? hashContent(fileEntry.content) : null;
|
||||
const blockHash = blockEntry ? hashContent(blockEntry.value || "") : null;
|
||||
|
||||
const lastFileHash = lastState.userFiles[label] || null;
|
||||
const lastBlockHash = lastState.userBlocks[label] || null;
|
||||
|
||||
const fileChanged = fileHash !== lastFileHash;
|
||||
const blockChanged = blockHash !== lastBlockHash;
|
||||
|
||||
const resolution = resolutions.get(label);
|
||||
|
||||
if (fileEntry && !blockEntry) {
|
||||
if (lastBlockHash && !fileChanged) {
|
||||
// Block was deleted elsewhere; delete file.
|
||||
await deleteMemoryFile(userDir, label);
|
||||
deletedFiles.push(label);
|
||||
delete userBlockIds[label];
|
||||
continue;
|
||||
}
|
||||
|
||||
const createdBlock = await client.blocks.create({
|
||||
label,
|
||||
value: fileEntry.content,
|
||||
description: `Memory block: ${label}`,
|
||||
limit: 20000,
|
||||
});
|
||||
if (createdBlock.id) {
|
||||
userBlockIds[label] = createdBlock.id;
|
||||
userBlockMap.set(label, createdBlock as Block);
|
||||
}
|
||||
createdBlocks.push(label);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!fileEntry && blockEntry) {
|
||||
if (lastFileHash && !blockChanged) {
|
||||
// File deleted, block unchanged -> delete block
|
||||
if (blockEntry.id) {
|
||||
await client.blocks.delete(blockEntry.id);
|
||||
}
|
||||
deletedBlocks.push(label);
|
||||
delete userBlockIds[label];
|
||||
continue;
|
||||
}
|
||||
|
||||
await writeMemoryFile(userDir, label, blockEntry.value || "");
|
||||
createdFiles.push(label);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!fileEntry || !blockEntry) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (fileChanged && blockChanged && !resolution) {
|
||||
conflicts.push({
|
||||
label,
|
||||
blockValue: blockEntry.value || "",
|
||||
fileValue: fileEntry.content,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (resolution?.resolution === "file") {
|
||||
if (blockEntry.id) {
|
||||
await client.blocks.update(blockEntry.id, {
|
||||
value: fileEntry.content,
|
||||
label,
|
||||
});
|
||||
}
|
||||
updatedBlocks.push(label);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (resolution?.resolution === "block") {
|
||||
await writeMemoryFile(userDir, label, blockEntry.value || "");
|
||||
updatedFiles.push(label);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (fileChanged && !blockChanged) {
|
||||
if (blockEntry.id) {
|
||||
await client.blocks.update(blockEntry.id, {
|
||||
value: fileEntry.content,
|
||||
label,
|
||||
});
|
||||
}
|
||||
updatedBlocks.push(label);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!fileChanged && blockChanged) {
|
||||
await writeMemoryFile(userDir, label, blockEntry.value || "");
|
||||
updatedFiles.push(label);
|
||||
}
|
||||
}
|
||||
|
||||
if (conflicts.length === 0) {
|
||||
const updatedBlocksList = await fetchAgentBlocks(agentId);
|
||||
const updatedSystemBlockMap = new Map(
|
||||
updatedBlocksList
|
||||
.filter(
|
||||
(block) =>
|
||||
block.label && block.label !== MEMORY_FILESYSTEM_BLOCK_LABEL,
|
||||
)
|
||||
.map((block) => [block.label as string, { value: block.value || "" }]),
|
||||
);
|
||||
|
||||
const updatedSystemFilesMap = await readMemoryFiles(systemDir);
|
||||
updatedSystemFilesMap.delete(MEMORY_FILESYSTEM_BLOCK_LABEL);
|
||||
const updatedUserFilesMap = await readMemoryFiles(userDir);
|
||||
const refreshedUserBlocks = new Map<string, { value: string }>();
|
||||
|
||||
for (const [label, blockId] of Object.entries(userBlockIds)) {
|
||||
try {
|
||||
const block = await client.blocks.retrieve(blockId);
|
||||
refreshedUserBlocks.set(label, { value: block.value || "" });
|
||||
} catch {
|
||||
delete userBlockIds[label];
|
||||
}
|
||||
}
|
||||
|
||||
const nextState = buildStateHashes(
|
||||
updatedSystemBlockMap,
|
||||
updatedSystemFilesMap,
|
||||
refreshedUserBlocks,
|
||||
updatedUserFilesMap,
|
||||
userBlockIds,
|
||||
);
|
||||
await saveSyncState(nextState, agentId, homeDir);
|
||||
}
|
||||
|
||||
return {
|
||||
updatedBlocks,
|
||||
createdBlocks,
|
||||
deletedBlocks,
|
||||
updatedFiles,
|
||||
createdFiles,
|
||||
deletedFiles,
|
||||
conflicts,
|
||||
};
|
||||
}
|
||||
|
||||
export async function updateMemoryFilesystemBlock(
|
||||
agentId: string,
|
||||
homeDir: string = homedir(),
|
||||
) {
|
||||
const systemDir = getMemorySystemDir(agentId, homeDir);
|
||||
const userDir = getMemoryUserDir(agentId, homeDir);
|
||||
|
||||
const systemFiles = await readMemoryFiles(systemDir);
|
||||
const userFiles = await readMemoryFiles(userDir);
|
||||
|
||||
const tree = renderMemoryFilesystemTree(
|
||||
Array.from(systemFiles.keys()).filter(
|
||||
(label) => label !== MEMORY_FILESYSTEM_BLOCK_LABEL,
|
||||
),
|
||||
Array.from(userFiles.keys()),
|
||||
);
|
||||
|
||||
const client = await getClient();
|
||||
await client.agents.blocks.update(MEMORY_FILESYSTEM_BLOCK_LABEL, {
|
||||
agent_id: agentId,
|
||||
value: tree,
|
||||
});
|
||||
|
||||
await writeMemoryFile(systemDir, MEMORY_FILESYSTEM_BLOCK_LABEL, tree);
|
||||
}
|
||||
|
||||
export async function ensureMemoryFilesystemBlock(agentId: string) {
|
||||
const client = await getClient();
|
||||
const blocks = await fetchAgentBlocks(agentId);
|
||||
const exists = blocks.some(
|
||||
(block) => block.label === MEMORY_FILESYSTEM_BLOCK_LABEL,
|
||||
);
|
||||
|
||||
if (exists) {
|
||||
return;
|
||||
}
|
||||
|
||||
const createdBlock = await client.blocks.create({
|
||||
label: MEMORY_FILESYSTEM_BLOCK_LABEL,
|
||||
value: "/memory/",
|
||||
description: "Filesystem view of memory blocks",
|
||||
limit: 20000,
|
||||
read_only: true,
|
||||
});
|
||||
|
||||
if (createdBlock.id) {
|
||||
await client.agents.blocks.attach(createdBlock.id, { agent_id: agentId });
|
||||
}
|
||||
}
|
||||
|
||||
export async function refreshMemoryFilesystemTree(
|
||||
agentId: string,
|
||||
homeDir: string = homedir(),
|
||||
) {
|
||||
ensureMemoryFilesystemDirs(agentId, homeDir);
|
||||
await updateMemoryFilesystemBlock(agentId, homeDir);
|
||||
}
|
||||
|
||||
export async function collectMemorySyncConflicts(
|
||||
agentId: string,
|
||||
homeDir: string = homedir(),
|
||||
): Promise<MemorySyncConflict[]> {
|
||||
const result = await syncMemoryFilesystem(agentId, { homeDir });
|
||||
return result.conflicts;
|
||||
}
|
||||
|
||||
export function formatMemorySyncSummary(result: MemorySyncResult): string {
|
||||
const lines = ["Memory filesystem sync complete:"];
|
||||
const pushCount = (label: string, count: number) => {
|
||||
if (count > 0) {
|
||||
lines.push(`⎿ ${label}: ${count}`);
|
||||
}
|
||||
};
|
||||
|
||||
pushCount("Blocks updated", result.updatedBlocks.length);
|
||||
pushCount("Blocks created", result.createdBlocks.length);
|
||||
pushCount("Blocks deleted", result.deletedBlocks.length);
|
||||
pushCount("Files updated", result.updatedFiles.length);
|
||||
pushCount("Files created", result.createdFiles.length);
|
||||
pushCount("Files deleted", result.deletedFiles.length);
|
||||
|
||||
if (result.conflicts.length > 0) {
|
||||
lines.push(`⎿ Conflicts: ${result.conflicts.length}`);
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Detach the memory_filesystem block from an agent.
|
||||
* Used when disabling memfs.
|
||||
*/
|
||||
export async function detachMemoryFilesystemBlock(
|
||||
agentId: string,
|
||||
): Promise<void> {
|
||||
const client = await getClient();
|
||||
const blocks = await fetchAgentBlocks(agentId);
|
||||
const memfsBlock = blocks.find(
|
||||
(block) => block.label === MEMORY_FILESYSTEM_BLOCK_LABEL,
|
||||
);
|
||||
|
||||
if (memfsBlock?.id) {
|
||||
await client.agents.blocks.detach(memfsBlock.id, { agent_id: agentId });
|
||||
}
|
||||
}
|
||||
@@ -266,3 +266,49 @@ export async function updateAgentSystemPrompt(
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates an agent's system prompt to include or exclude the memfs addon section.
|
||||
*
|
||||
* @param agentId - The agent ID to update
|
||||
* @param enableMemfs - Whether to enable (add) or disable (remove) the memfs addon
|
||||
* @returns Result with success status and message
|
||||
*/
|
||||
export async function updateAgentSystemPromptMemfs(
|
||||
agentId: string,
|
||||
enableMemfs: boolean,
|
||||
): Promise<SystemPromptUpdateResult> {
|
||||
try {
|
||||
const client = await getClient();
|
||||
const agent = await client.agents.retrieve(agentId);
|
||||
let currentSystemPrompt = agent.system || "";
|
||||
|
||||
const { SYSTEM_PROMPT_MEMFS_ADDON } = await import("./promptAssets");
|
||||
|
||||
// Remove any existing memfs addon section (to avoid duplicates)
|
||||
// Look for the "## Memory Filesystem" header
|
||||
const memfsHeaderRegex = /\n## Memory Filesystem[\s\S]*?(?=\n# |$)/;
|
||||
currentSystemPrompt = currentSystemPrompt.replace(memfsHeaderRegex, "");
|
||||
|
||||
// If enabling, append the memfs addon
|
||||
if (enableMemfs) {
|
||||
currentSystemPrompt = `${currentSystemPrompt}${SYSTEM_PROMPT_MEMFS_ADDON}`;
|
||||
}
|
||||
|
||||
await client.agents.update(agentId, {
|
||||
system: currentSystemPrompt,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: enableMemfs
|
||||
? "System prompt updated to include Memory Filesystem section"
|
||||
: "System prompt updated to remove Memory Filesystem section",
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
message: `Failed to update system prompt memfs: ${error instanceof Error ? error.message : String(error)}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import lettaCodexPrompt from "./prompts/letta_codex.md";
|
||||
import lettaGeminiPrompt from "./prompts/letta_gemini.md";
|
||||
import loadedSkillsPrompt from "./prompts/loaded_skills.mdx";
|
||||
import memoryCheckReminder from "./prompts/memory_check_reminder.txt";
|
||||
import memoryFilesystemPrompt from "./prompts/memory_filesystem.mdx";
|
||||
import personaPrompt from "./prompts/persona.mdx";
|
||||
import personaClaudePrompt from "./prompts/persona_claude.mdx";
|
||||
import personaKawaiiPrompt from "./prompts/persona_kawaii.mdx";
|
||||
@@ -24,8 +25,10 @@ import skillUnloadReminder from "./prompts/skill_unload_reminder.txt";
|
||||
import skillsPrompt from "./prompts/skills.mdx";
|
||||
import stylePrompt from "./prompts/style.mdx";
|
||||
import systemPrompt from "./prompts/system_prompt.txt";
|
||||
import systemPromptMemfsAddon from "./prompts/system_prompt_memfs.txt";
|
||||
|
||||
export const SYSTEM_PROMPT = systemPrompt;
|
||||
export const SYSTEM_PROMPT_MEMFS_ADDON = systemPromptMemfsAddon;
|
||||
export const PLAN_MODE_REMINDER = planModeReminder;
|
||||
export const SKILL_UNLOAD_REMINDER = skillUnloadReminder;
|
||||
export const SKILL_CREATOR_PROMPT = skillCreatorModePrompt;
|
||||
@@ -43,6 +46,7 @@ export const MEMORY_PROMPTS: Record<string, string> = {
|
||||
"project.mdx": projectPrompt,
|
||||
"skills.mdx": skillsPrompt,
|
||||
"loaded_skills.mdx": loadedSkillsPrompt,
|
||||
"memory_filesystem.mdx": memoryFilesystemPrompt,
|
||||
"style.mdx": stylePrompt,
|
||||
};
|
||||
|
||||
|
||||
7
src/agent/prompts/memory_filesystem.mdx
Normal file
7
src/agent/prompts/memory_filesystem.mdx
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
label: memory_filesystem
|
||||
description: Filesystem view of memory blocks (system + user)
|
||||
limit: 20000
|
||||
---
|
||||
|
||||
/memory/
|
||||
@@ -39,4 +39,4 @@ How to use Skills:
|
||||
- Then, navigate and discover additional linked files in its directory as needed. Don't load additional files immediately, only load them when needed.
|
||||
- When the task is completed, unload irrelevant skills using the Skill tool with `command: "unload"`.
|
||||
- After creating a new skill, use `command: "refresh"` to re-scan the skills directory and update the available skills list.
|
||||
IMPORTANT: Always unload irrelevant skills using the Skill tool to free up context space.
|
||||
IMPORTANT: Always unload irrelevant skills using the Skill tool to free up context space.
|
||||
|
||||
34
src/agent/prompts/system_prompt_memfs.txt
Normal file
34
src/agent/prompts/system_prompt_memfs.txt
Normal file
@@ -0,0 +1,34 @@
|
||||
|
||||
## Memory Filesystem
|
||||
Your memory blocks are synchronized with a filesystem tree at `~/.letta/agents/<agent-id>/memory/`. This provides:
|
||||
- **Persistent storage**: Memory edits survive server restarts and can be version-controlled
|
||||
- **Two-way sync**: Changes to files sync to memory blocks, and vice versa
|
||||
- **Visibility**: A `memory_filesystem` block shows the tree structure of all memory files
|
||||
|
||||
### Structure
|
||||
```
|
||||
~/.letta/agents/<agent-id>/memory/
|
||||
├── system/ # System prompt memory blocks (attached to agent)
|
||||
│ ├── persona/ # Your identity and approach
|
||||
│ ├── human.md # What you know about the user
|
||||
│ └── ...
|
||||
├── user/ # User notes (detached blocks, not in system prompt)
|
||||
│ └── ...
|
||||
└── .sync-state.json # Internal sync state (do not edit)
|
||||
```
|
||||
|
||||
### System vs User
|
||||
- **system/**: Memory blocks attached to your system prompt. These influence your behavior and are always loaded.
|
||||
- **user/**: Detached blocks for reference/notes. Created as blocks but NOT attached to the agent (similar to the "note" tool pattern).
|
||||
|
||||
### Sync Behavior
|
||||
- **Startup**: Automatic sync when the CLI starts
|
||||
- **After memory edits**: Automatic sync after using memory tools
|
||||
- **Manual**: Run `/memory-sync` to sync on demand
|
||||
- **Conflicts**: If both file and block changed, you'll be prompted to choose which version to keep
|
||||
|
||||
### How It Works
|
||||
1. Each `.md` file path maps to a block label (e.g., `system/persona/git_safety.md` → label `persona/git_safety`)
|
||||
2. File content syncs with block `value`
|
||||
3. Changes detected via content hashing
|
||||
4. The `memory_filesystem` block auto-updates with the tree view
|
||||
505
src/cli/App.tsx
505
src/cli/App.tsx
@@ -42,6 +42,16 @@ import { getClient } from "../agent/client";
|
||||
import { getCurrentAgentId, setCurrentAgentId } from "../agent/context";
|
||||
import { type AgentProvenance, createAgent } from "../agent/create";
|
||||
import { ISOLATED_BLOCK_LABELS } from "../agent/memory";
|
||||
import {
|
||||
detachMemoryFilesystemBlock,
|
||||
ensureMemoryFilesystemBlock,
|
||||
formatMemorySyncSummary,
|
||||
getMemoryFilesystemRoot,
|
||||
type MemorySyncConflict,
|
||||
type MemorySyncResolution,
|
||||
syncMemoryFilesystem,
|
||||
updateMemoryFilesystemBlock,
|
||||
} from "../agent/memoryFilesystem";
|
||||
import { sendMessageStream } from "../agent/message";
|
||||
import { getModelInfo, getModelShortName } from "../agent/model";
|
||||
import { INTERRUPT_RECOVERY_ALERT } from "../agent/promptAssets";
|
||||
@@ -108,6 +118,7 @@ import { ErrorMessage } from "./components/ErrorMessageRich";
|
||||
import { FeedbackDialog } from "./components/FeedbackDialog";
|
||||
import { HelpDialog } from "./components/HelpDialog";
|
||||
import { HooksManager } from "./components/HooksManager";
|
||||
import { InlineQuestionApproval } from "./components/InlineQuestionApproval";
|
||||
import { Input } from "./components/InputRich";
|
||||
import { McpConnectFlow } from "./components/McpConnectFlow";
|
||||
import { McpSelector } from "./components/McpSelector";
|
||||
@@ -118,7 +129,6 @@ import { NewAgentDialog } from "./components/NewAgentDialog";
|
||||
import { PendingApprovalStub } from "./components/PendingApprovalStub";
|
||||
import { PinDialog, validateAgentName } from "./components/PinDialog";
|
||||
import { ProviderSelector } from "./components/ProviderSelector";
|
||||
// QuestionDialog removed - now using InlineQuestionApproval
|
||||
import { ReasoningMessage } from "./components/ReasoningMessageRich";
|
||||
|
||||
import { formatUsageStats } from "./components/SessionStats";
|
||||
@@ -975,6 +985,7 @@ export default function App({
|
||||
| "subagent"
|
||||
| "feedback"
|
||||
| "memory"
|
||||
| "memory-sync"
|
||||
| "pin"
|
||||
| "new"
|
||||
| "mcp"
|
||||
@@ -984,6 +995,14 @@ export default function App({
|
||||
| "connect"
|
||||
| null;
|
||||
const [activeOverlay, setActiveOverlay] = useState<ActiveOverlay>(null);
|
||||
const [memorySyncConflicts, setMemorySyncConflicts] = useState<
|
||||
MemorySyncConflict[] | null
|
||||
>(null);
|
||||
const memorySyncProcessedToolCallsRef = useRef<Set<string>>(new Set());
|
||||
const memorySyncCommandIdRef = useRef<string | null>(null);
|
||||
const memorySyncCommandInputRef = useRef<string>("/memory-sync");
|
||||
const memorySyncInFlightRef = useRef(false);
|
||||
const memoryFilesystemInitializedRef = useRef(false);
|
||||
const [feedbackPrefill, setFeedbackPrefill] = useState("");
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [modelSelectorOptions, setModelSelectorOptions] = useState<{
|
||||
@@ -1869,6 +1888,238 @@ export default function App({
|
||||
[refreshDerived, currentModelId],
|
||||
);
|
||||
|
||||
const updateMemorySyncCommand = useCallback(
|
||||
(
|
||||
commandId: string,
|
||||
output: string,
|
||||
success: boolean,
|
||||
input = "/memory-sync",
|
||||
keepRunning = false, // If true, keep phase as "running" (for conflict dialogs)
|
||||
) => {
|
||||
buffersRef.current.byId.set(commandId, {
|
||||
kind: "command",
|
||||
id: commandId,
|
||||
input,
|
||||
output,
|
||||
phase: keepRunning ? "running" : "finished",
|
||||
success,
|
||||
});
|
||||
refreshDerived();
|
||||
},
|
||||
[refreshDerived],
|
||||
);
|
||||
|
||||
const runMemoryFilesystemSync = useCallback(
|
||||
async (source: "startup" | "auto" | "command", commandId?: string) => {
|
||||
if (!agentId || agentId === "loading") {
|
||||
return;
|
||||
}
|
||||
if (memorySyncInFlightRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
memorySyncInFlightRef.current = true;
|
||||
|
||||
try {
|
||||
await ensureMemoryFilesystemBlock(agentId);
|
||||
const result = await syncMemoryFilesystem(agentId);
|
||||
|
||||
if (result.conflicts.length > 0) {
|
||||
memorySyncCommandIdRef.current = commandId ?? null;
|
||||
setMemorySyncConflicts(result.conflicts);
|
||||
setActiveOverlay("memory-sync");
|
||||
|
||||
if (commandId) {
|
||||
updateMemorySyncCommand(
|
||||
commandId,
|
||||
`Memory sync paused — resolve ${result.conflicts.length} conflict${
|
||||
result.conflicts.length === 1 ? "" : "s"
|
||||
} to continue.`,
|
||||
false,
|
||||
"/memory-sync",
|
||||
true, // keepRunning - don't commit until conflicts resolved
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
await updateMemoryFilesystemBlock(agentId);
|
||||
|
||||
if (commandId) {
|
||||
updateMemorySyncCommand(
|
||||
commandId,
|
||||
formatMemorySyncSummary(result),
|
||||
true,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
const errorText = formatErrorDetails(error, agentId);
|
||||
if (commandId) {
|
||||
updateMemorySyncCommand(commandId, `Failed: ${errorText}`, false);
|
||||
} else if (source !== "startup") {
|
||||
appendError(`Memory sync failed: ${errorText}`);
|
||||
} else {
|
||||
console.error(`Memory sync failed: ${errorText}`);
|
||||
}
|
||||
} finally {
|
||||
memorySyncInFlightRef.current = false;
|
||||
}
|
||||
},
|
||||
[agentId, appendError, updateMemorySyncCommand],
|
||||
);
|
||||
|
||||
const maybeSyncMemoryFilesystemAfterTurn = useCallback(async () => {
|
||||
// Only auto-sync if memfs is enabled for this agent
|
||||
if (!agentId || agentId === "loading") return;
|
||||
if (!settingsManager.isMemfsEnabled(agentId)) return;
|
||||
|
||||
const newToolCallIds: string[] = [];
|
||||
for (const line of buffersRef.current.byId.values()) {
|
||||
if (line.kind !== "tool_call") continue;
|
||||
if (!line.toolCallId || !line.name) continue;
|
||||
if (line.name !== "memory" && line.name !== "memory_apply_patch")
|
||||
continue;
|
||||
if (memorySyncProcessedToolCallsRef.current.has(line.toolCallId))
|
||||
continue;
|
||||
newToolCallIds.push(line.toolCallId);
|
||||
}
|
||||
|
||||
if (newToolCallIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const id of newToolCallIds) {
|
||||
memorySyncProcessedToolCallsRef.current.add(id);
|
||||
}
|
||||
await runMemoryFilesystemSync("auto");
|
||||
}, [agentId, runMemoryFilesystemSync]);
|
||||
|
||||
useEffect(() => {
|
||||
if (loadingState !== "ready") {
|
||||
return;
|
||||
}
|
||||
if (!agentId || agentId === "loading") {
|
||||
return;
|
||||
}
|
||||
if (memoryFilesystemInitializedRef.current) {
|
||||
return;
|
||||
}
|
||||
// Only run startup sync if memfs is enabled for this agent
|
||||
if (!settingsManager.isMemfsEnabled(agentId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
memoryFilesystemInitializedRef.current = true;
|
||||
runMemoryFilesystemSync("startup");
|
||||
}, [agentId, loadingState, runMemoryFilesystemSync]);
|
||||
|
||||
const handleMemorySyncConflictSubmit = useCallback(
|
||||
async (answers: Record<string, string>) => {
|
||||
if (!agentId || agentId === "loading" || !memorySyncConflicts) {
|
||||
return;
|
||||
}
|
||||
|
||||
const commandId = memorySyncCommandIdRef.current;
|
||||
const commandInput = memorySyncCommandInputRef.current;
|
||||
memorySyncCommandIdRef.current = null;
|
||||
memorySyncCommandInputRef.current = "/memory-sync";
|
||||
|
||||
const resolutions: MemorySyncResolution[] = memorySyncConflicts.map(
|
||||
(conflict) => {
|
||||
const answer = answers[`Conflict for ${conflict.label}`];
|
||||
return {
|
||||
label: conflict.label,
|
||||
resolution: answer === "Use file version" ? "file" : "block",
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
setMemorySyncConflicts(null);
|
||||
setActiveOverlay(null);
|
||||
|
||||
if (memorySyncInFlightRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
memorySyncInFlightRef.current = true;
|
||||
|
||||
try {
|
||||
const result = await syncMemoryFilesystem(agentId, {
|
||||
resolutions,
|
||||
});
|
||||
|
||||
if (result.conflicts.length > 0) {
|
||||
setMemorySyncConflicts(result.conflicts);
|
||||
setActiveOverlay("memory-sync");
|
||||
if (commandId) {
|
||||
updateMemorySyncCommand(
|
||||
commandId,
|
||||
`Memory sync paused — resolve ${result.conflicts.length} conflict${
|
||||
result.conflicts.length === 1 ? "" : "s"
|
||||
} to continue.`,
|
||||
false,
|
||||
commandInput,
|
||||
true, // keepRunning - don't commit until all conflicts resolved
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
await updateMemoryFilesystemBlock(agentId);
|
||||
|
||||
// Format resolution summary (align with formatMemorySyncSummary which uses "⎿ " prefix)
|
||||
const resolutionSummary = resolutions
|
||||
.map(
|
||||
(r) =>
|
||||
`⎿ ${r.label}: used ${r.resolution === "file" ? "file" : "block"} version`,
|
||||
)
|
||||
.join("\n");
|
||||
|
||||
if (commandId) {
|
||||
updateMemorySyncCommand(
|
||||
commandId,
|
||||
`${formatMemorySyncSummary(result)}\nConflicts resolved:\n${resolutionSummary}`,
|
||||
true,
|
||||
commandInput,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
const errorText = formatErrorDetails(error, agentId);
|
||||
if (commandId) {
|
||||
updateMemorySyncCommand(
|
||||
commandId,
|
||||
`Failed: ${errorText}`,
|
||||
false,
|
||||
commandInput,
|
||||
);
|
||||
} else {
|
||||
appendError(`Memory sync failed: ${errorText}`);
|
||||
}
|
||||
} finally {
|
||||
memorySyncInFlightRef.current = false;
|
||||
}
|
||||
},
|
||||
[agentId, appendError, memorySyncConflicts, updateMemorySyncCommand],
|
||||
);
|
||||
|
||||
const handleMemorySyncConflictCancel = useCallback(() => {
|
||||
const commandId = memorySyncCommandIdRef.current;
|
||||
const commandInput = memorySyncCommandInputRef.current;
|
||||
memorySyncCommandIdRef.current = null;
|
||||
memorySyncCommandInputRef.current = "/memory-sync";
|
||||
setMemorySyncConflicts(null);
|
||||
setActiveOverlay(null);
|
||||
|
||||
if (commandId) {
|
||||
updateMemorySyncCommand(
|
||||
commandId,
|
||||
"Memory sync cancelled.",
|
||||
false,
|
||||
commandInput,
|
||||
);
|
||||
}
|
||||
}, [updateMemorySyncCommand]);
|
||||
|
||||
// Core streaming function - iterative loop that processes conversation turns
|
||||
const processConversation = useCallback(
|
||||
async (
|
||||
@@ -2525,6 +2776,8 @@ export default function App({
|
||||
queueSnapshotRef.current = [];
|
||||
}
|
||||
|
||||
await maybeSyncMemoryFilesystemAfterTurn();
|
||||
|
||||
// === RALPH WIGGUM CONTINUATION CHECK ===
|
||||
// Check if ralph mode is active and should auto-continue
|
||||
// This happens at the very end, right before we'd release input
|
||||
@@ -3575,6 +3828,7 @@ export default function App({
|
||||
needsEagerApprovalCheck,
|
||||
queueApprovalResults,
|
||||
consumeQueuedMessages,
|
||||
maybeSyncMemoryFilesystemAfterTurn,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -5927,6 +6181,231 @@ export default function App({
|
||||
return { submitted: true };
|
||||
}
|
||||
|
||||
// Special handling for /memory-sync command - sync filesystem memory
|
||||
if (trimmed === "/memory-sync") {
|
||||
// Check if memfs is enabled for this agent
|
||||
if (!settingsManager.isMemfsEnabled(agentId)) {
|
||||
const cmdId = uid("cmd");
|
||||
buffersRef.current.byId.set(cmdId, {
|
||||
kind: "command",
|
||||
id: cmdId,
|
||||
input: msg,
|
||||
output:
|
||||
"Memory filesystem is disabled. Run `/memfs enable` first.",
|
||||
phase: "finished",
|
||||
success: false,
|
||||
});
|
||||
buffersRef.current.order.push(cmdId);
|
||||
refreshDerived();
|
||||
return { submitted: true };
|
||||
}
|
||||
|
||||
const cmdId = uid("cmd");
|
||||
buffersRef.current.byId.set(cmdId, {
|
||||
kind: "command",
|
||||
id: cmdId,
|
||||
input: msg,
|
||||
output: "Syncing memory filesystem...",
|
||||
phase: "running",
|
||||
});
|
||||
buffersRef.current.order.push(cmdId);
|
||||
refreshDerived();
|
||||
|
||||
setCommandRunning(true);
|
||||
|
||||
try {
|
||||
await runMemoryFilesystemSync("command", cmdId);
|
||||
} finally {
|
||||
setCommandRunning(false);
|
||||
}
|
||||
|
||||
return { submitted: true };
|
||||
}
|
||||
|
||||
// Special handling for /memfs command - enable/disable filesystem-backed memory
|
||||
if (trimmed.startsWith("/memfs")) {
|
||||
const [, subcommand] = trimmed.split(/\s+/);
|
||||
const cmdId = uid("cmd");
|
||||
|
||||
if (!subcommand || subcommand === "status") {
|
||||
// Show status
|
||||
const enabled = settingsManager.isMemfsEnabled(agentId);
|
||||
let output: string;
|
||||
if (enabled) {
|
||||
const memoryDir = getMemoryFilesystemRoot(agentId);
|
||||
output = `Memory filesystem is enabled.\nPath: ${memoryDir}`;
|
||||
} else {
|
||||
output =
|
||||
"Memory filesystem is disabled. Run `/memfs enable` to enable.";
|
||||
}
|
||||
buffersRef.current.byId.set(cmdId, {
|
||||
kind: "command",
|
||||
id: cmdId,
|
||||
input: msg,
|
||||
output,
|
||||
phase: "finished",
|
||||
success: true,
|
||||
});
|
||||
buffersRef.current.order.push(cmdId);
|
||||
refreshDerived();
|
||||
return { submitted: true };
|
||||
}
|
||||
|
||||
if (subcommand === "enable") {
|
||||
buffersRef.current.byId.set(cmdId, {
|
||||
kind: "command",
|
||||
id: cmdId,
|
||||
input: msg,
|
||||
output: "Enabling memory filesystem...",
|
||||
phase: "running",
|
||||
});
|
||||
buffersRef.current.order.push(cmdId);
|
||||
refreshDerived();
|
||||
setCommandRunning(true);
|
||||
|
||||
try {
|
||||
// 1. Detach memory tools from agent
|
||||
const { detachMemoryTools } = await import("../tools/toolset");
|
||||
await detachMemoryTools(agentId);
|
||||
|
||||
// 2. Update settings
|
||||
settingsManager.setMemfsEnabled(agentId, true);
|
||||
|
||||
// 3. Update system prompt to include memfs section
|
||||
const { updateAgentSystemPromptMemfs } = await import(
|
||||
"../agent/modify"
|
||||
);
|
||||
await updateAgentSystemPromptMemfs(agentId, true);
|
||||
|
||||
// 4. Run initial sync (creates files from blocks)
|
||||
await ensureMemoryFilesystemBlock(agentId);
|
||||
const result = await syncMemoryFilesystem(agentId);
|
||||
|
||||
if (result.conflicts.length > 0) {
|
||||
// Handle conflicts - show overlay (keep running so it stays in liveItems)
|
||||
memorySyncCommandIdRef.current = cmdId;
|
||||
memorySyncCommandInputRef.current = msg;
|
||||
setMemorySyncConflicts(result.conflicts);
|
||||
setActiveOverlay("memory-sync");
|
||||
updateMemorySyncCommand(
|
||||
cmdId,
|
||||
`Memory filesystem enabled with ${result.conflicts.length} conflict${result.conflicts.length === 1 ? "" : "s"} to resolve.`,
|
||||
false,
|
||||
msg,
|
||||
true, // keepRunning - don't commit until conflict resolved
|
||||
);
|
||||
} else {
|
||||
await updateMemoryFilesystemBlock(agentId);
|
||||
const memoryDir = getMemoryFilesystemRoot(agentId);
|
||||
updateMemorySyncCommand(
|
||||
cmdId,
|
||||
`Memory filesystem enabled.\nPath: ${memoryDir}\n${formatMemorySyncSummary(result)}`,
|
||||
true,
|
||||
msg,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
const errorText =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
updateMemorySyncCommand(
|
||||
cmdId,
|
||||
`Failed to enable memfs: ${errorText}`,
|
||||
false,
|
||||
msg,
|
||||
);
|
||||
} finally {
|
||||
setCommandRunning(false);
|
||||
}
|
||||
|
||||
return { submitted: true };
|
||||
}
|
||||
|
||||
if (subcommand === "disable") {
|
||||
buffersRef.current.byId.set(cmdId, {
|
||||
kind: "command",
|
||||
id: cmdId,
|
||||
input: msg,
|
||||
output: "Disabling memory filesystem...",
|
||||
phase: "running",
|
||||
});
|
||||
buffersRef.current.order.push(cmdId);
|
||||
refreshDerived();
|
||||
setCommandRunning(true);
|
||||
|
||||
try {
|
||||
// 1. Run final sync to ensure blocks are up-to-date
|
||||
const result = await syncMemoryFilesystem(agentId);
|
||||
|
||||
if (result.conflicts.length > 0) {
|
||||
// Handle conflicts - show overlay (keep running so it stays in liveItems)
|
||||
memorySyncCommandIdRef.current = cmdId;
|
||||
memorySyncCommandInputRef.current = msg;
|
||||
setMemorySyncConflicts(result.conflicts);
|
||||
setActiveOverlay("memory-sync");
|
||||
updateMemorySyncCommand(
|
||||
cmdId,
|
||||
`Cannot disable: resolve ${result.conflicts.length} conflict${result.conflicts.length === 1 ? "" : "s"} first.`,
|
||||
false,
|
||||
msg,
|
||||
true, // keepRunning - don't commit until conflict resolved
|
||||
);
|
||||
return { submitted: true };
|
||||
}
|
||||
|
||||
// 2. Re-attach memory tool
|
||||
const { reattachMemoryTool } = await import("../tools/toolset");
|
||||
// Use current model or default to Claude
|
||||
const modelId = currentModelId || "anthropic/claude-sonnet-4";
|
||||
await reattachMemoryTool(agentId, modelId);
|
||||
|
||||
// 3. Detach memory_filesystem block
|
||||
await detachMemoryFilesystemBlock(agentId);
|
||||
|
||||
// 4. Update system prompt to remove memfs section
|
||||
const { updateAgentSystemPromptMemfs } = await import(
|
||||
"../agent/modify"
|
||||
);
|
||||
await updateAgentSystemPromptMemfs(agentId, false);
|
||||
|
||||
// 5. Update settings
|
||||
settingsManager.setMemfsEnabled(agentId, false);
|
||||
|
||||
updateMemorySyncCommand(
|
||||
cmdId,
|
||||
"Memory filesystem disabled. Memory tool re-attached.\nFiles on disk have been kept.",
|
||||
true,
|
||||
msg,
|
||||
);
|
||||
} catch (error) {
|
||||
const errorText =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
updateMemorySyncCommand(
|
||||
cmdId,
|
||||
`Failed to disable memfs: ${errorText}`,
|
||||
false,
|
||||
msg,
|
||||
);
|
||||
} finally {
|
||||
setCommandRunning(false);
|
||||
}
|
||||
|
||||
return { submitted: true };
|
||||
}
|
||||
|
||||
// Unknown subcommand
|
||||
buffersRef.current.byId.set(cmdId, {
|
||||
kind: "command",
|
||||
id: cmdId,
|
||||
input: msg,
|
||||
output: `Unknown subcommand: ${subcommand}. Use /memfs, /memfs enable, or /memfs disable.`,
|
||||
phase: "finished",
|
||||
success: false,
|
||||
});
|
||||
buffersRef.current.order.push(cmdId);
|
||||
refreshDerived();
|
||||
return { submitted: true };
|
||||
}
|
||||
|
||||
// Special handling for /skill command - enter skill creation mode
|
||||
if (trimmed.startsWith("/skill")) {
|
||||
// Check for pending approvals before sending
|
||||
@@ -9768,6 +10247,30 @@ Plan file path: ${planFilePath}`;
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Memory Sync Conflict Resolver */}
|
||||
{activeOverlay === "memory-sync" && memorySyncConflicts && (
|
||||
<InlineQuestionApproval
|
||||
questions={memorySyncConflicts.map((conflict) => ({
|
||||
header: "Memory sync",
|
||||
question: `Conflict for ${conflict.label}`,
|
||||
options: [
|
||||
{
|
||||
label: "Use file version",
|
||||
description: "Overwrite memory block with file contents",
|
||||
},
|
||||
{
|
||||
label: "Use block version",
|
||||
description: "Overwrite file with memory block contents",
|
||||
},
|
||||
],
|
||||
multiSelect: false,
|
||||
allowOther: false, // Only file or block - no custom option
|
||||
}))}
|
||||
onSubmit={handleMemorySyncConflictSubmit}
|
||||
onCancel={handleMemorySyncConflictCancel}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* MCP Server Selector - conditionally mounted as overlay */}
|
||||
{activeOverlay === "mcp" && (
|
||||
<McpSelector
|
||||
|
||||
@@ -60,6 +60,23 @@ export const commands: Record<string, Command> = {
|
||||
return "Opening memory viewer...";
|
||||
},
|
||||
},
|
||||
"/memory-sync": {
|
||||
desc: "Sync memory blocks with filesystem (requires memfs enabled)",
|
||||
order: 15.5,
|
||||
handler: () => {
|
||||
// Handled specially in App.tsx to run filesystem sync
|
||||
return "Syncing memory filesystem...";
|
||||
},
|
||||
},
|
||||
"/memfs": {
|
||||
desc: "Enable/disable filesystem-backed memory (/memfs [enable|disable])",
|
||||
args: "[enable|disable]",
|
||||
order: 15.6,
|
||||
handler: () => {
|
||||
// Handled specially in App.tsx
|
||||
return "Managing memory filesystem...";
|
||||
},
|
||||
},
|
||||
"/search": {
|
||||
desc: "Search messages across all agents (/search [query])",
|
||||
order: 16,
|
||||
|
||||
@@ -15,6 +15,7 @@ interface Question {
|
||||
header: string;
|
||||
options: QuestionOption[];
|
||||
multiSelect: boolean;
|
||||
allowOther?: boolean; // default true - set false to hide "Type something" option
|
||||
}
|
||||
|
||||
type Props = {
|
||||
@@ -46,12 +47,13 @@ export const InlineQuestionApproval = memo(
|
||||
|
||||
const currentQuestion = questions[currentQuestionIndex];
|
||||
|
||||
// Build options list: regular options + "Type something"
|
||||
// Build options list: regular options + "Type something" (unless allowOther=false)
|
||||
// For multi-select, we also track a separate "Submit" action
|
||||
const showOther = currentQuestion?.allowOther !== false;
|
||||
const baseOptions = currentQuestion
|
||||
? [
|
||||
...currentQuestion.options,
|
||||
{ label: "Type something.", description: "" },
|
||||
...(showOther ? [{ label: "Type something.", description: "" }] : []),
|
||||
]
|
||||
: [];
|
||||
|
||||
@@ -60,12 +62,12 @@ export const InlineQuestionApproval = memo(
|
||||
? [...baseOptions, { label: "Submit", description: "" }]
|
||||
: baseOptions;
|
||||
|
||||
const customOptionIndex = baseOptions.length - 1; // "Type something" index
|
||||
const customOptionIndex = showOther ? baseOptions.length - 1 : -1; // "Type something" index (-1 if disabled)
|
||||
const submitOptionIndex = currentQuestion?.multiSelect
|
||||
? optionsWithOther.length - 1
|
||||
: -1; // Submit index (only for multi-select)
|
||||
|
||||
const isOnCustomOption = selectedOption === customOptionIndex;
|
||||
const isOnCustomOption = showOther && selectedOption === customOptionIndex;
|
||||
const isOnSubmitOption = selectedOption === submitOptionIndex;
|
||||
|
||||
const handleSubmitAnswer = (answer: string) => {
|
||||
|
||||
@@ -318,10 +318,7 @@ export function ModelSelector({
|
||||
const serverRecommendedModels = useMemo(() => {
|
||||
if (!isSelfHosted || availableHandles === undefined) return [];
|
||||
const available = typedModels.filter(
|
||||
(m) =>
|
||||
availableHandles !== null &&
|
||||
availableHandles.has(m.handle) &&
|
||||
m.handle !== "letta/letta-free",
|
||||
(m) => availableHandles?.has(m.handle) && m.handle !== "letta/letta-free",
|
||||
);
|
||||
if (searchQuery) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
|
||||
@@ -1,236 +0,0 @@
|
||||
import { Box, Text, useInput } from "ink";
|
||||
import { memo, useState } from "react";
|
||||
import { colors } from "./colors";
|
||||
import { PasteAwareTextInput } from "./PasteAwareTextInput";
|
||||
|
||||
interface QuestionOption {
|
||||
label: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
interface Question {
|
||||
question: string;
|
||||
header: string;
|
||||
options: QuestionOption[];
|
||||
multiSelect: boolean;
|
||||
}
|
||||
|
||||
type Props = {
|
||||
questions: Question[];
|
||||
onSubmit: (answers: Record<string, string>) => void;
|
||||
onCancel?: () => void;
|
||||
};
|
||||
|
||||
export const QuestionDialog = memo(
|
||||
({ questions, onSubmit, onCancel }: Props) => {
|
||||
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0);
|
||||
const [answers, setAnswers] = useState<Record<string, string>>({});
|
||||
const [selectedOption, setSelectedOption] = useState(0);
|
||||
const [isOtherMode, setIsOtherMode] = useState(false);
|
||||
const [otherText, setOtherText] = useState("");
|
||||
const [selectedMulti, setSelectedMulti] = useState<Set<number>>(new Set());
|
||||
|
||||
const currentQuestion = questions[currentQuestionIndex];
|
||||
const optionsWithOther = currentQuestion
|
||||
? [
|
||||
...currentQuestion.options,
|
||||
{ label: "Other", description: "Provide a custom response" },
|
||||
]
|
||||
: [];
|
||||
|
||||
const handleSubmitAnswer = (answer: string) => {
|
||||
if (!currentQuestion) return;
|
||||
const newAnswers = {
|
||||
...answers,
|
||||
[currentQuestion.question]: answer,
|
||||
};
|
||||
setAnswers(newAnswers);
|
||||
|
||||
if (currentQuestionIndex < questions.length - 1) {
|
||||
setCurrentQuestionIndex(currentQuestionIndex + 1);
|
||||
setSelectedOption(0);
|
||||
setIsOtherMode(false);
|
||||
setOtherText("");
|
||||
setSelectedMulti(new Set());
|
||||
} else {
|
||||
onSubmit(newAnswers);
|
||||
}
|
||||
};
|
||||
|
||||
useInput((input, key) => {
|
||||
if (!currentQuestion) return;
|
||||
|
||||
// CTRL-C: immediately cancel (works in any mode)
|
||||
if (key.ctrl && input === "c") {
|
||||
if (onCancel) {
|
||||
onCancel();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (isOtherMode) {
|
||||
if (key.escape) {
|
||||
setIsOtherMode(false);
|
||||
setOtherText("");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// ESC in main selection mode: cancel the dialog
|
||||
if (key.escape) {
|
||||
if (onCancel) {
|
||||
onCancel();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (key.upArrow) {
|
||||
setSelectedOption((prev) => Math.max(0, prev - 1));
|
||||
} else if (key.downArrow) {
|
||||
setSelectedOption((prev) =>
|
||||
Math.min(optionsWithOther.length - 1, prev + 1),
|
||||
);
|
||||
} else if (key.return) {
|
||||
if (currentQuestion.multiSelect) {
|
||||
if (selectedOption === optionsWithOther.length - 1) {
|
||||
setIsOtherMode(true);
|
||||
} else if (selectedMulti.size > 0) {
|
||||
const selectedLabels = Array.from(selectedMulti)
|
||||
.map((i) => optionsWithOther[i]?.label)
|
||||
.filter(Boolean)
|
||||
.join(", ");
|
||||
handleSubmitAnswer(selectedLabels);
|
||||
}
|
||||
} else {
|
||||
if (selectedOption === optionsWithOther.length - 1) {
|
||||
setIsOtherMode(true);
|
||||
} else {
|
||||
handleSubmitAnswer(optionsWithOther[selectedOption]?.label || "");
|
||||
}
|
||||
}
|
||||
} else if (input === " " && currentQuestion.multiSelect) {
|
||||
if (selectedOption < optionsWithOther.length - 1) {
|
||||
setSelectedMulti((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(selectedOption)) {
|
||||
newSet.delete(selectedOption);
|
||||
} else {
|
||||
newSet.add(selectedOption);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
}
|
||||
} else if (input >= "1" && input <= "9") {
|
||||
const optionIndex = Number.parseInt(input, 10) - 1;
|
||||
if (optionIndex < optionsWithOther.length) {
|
||||
if (currentQuestion.multiSelect) {
|
||||
if (optionIndex < optionsWithOther.length - 1) {
|
||||
setSelectedMulti((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(optionIndex)) {
|
||||
newSet.delete(optionIndex);
|
||||
} else {
|
||||
newSet.add(optionIndex);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
}
|
||||
} else {
|
||||
if (optionIndex === optionsWithOther.length - 1) {
|
||||
setIsOtherMode(true);
|
||||
} else {
|
||||
handleSubmitAnswer(optionsWithOther[optionIndex]?.label || "");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const handleOtherSubmit = (text: string) => {
|
||||
handleSubmitAnswer(text);
|
||||
};
|
||||
|
||||
if (!currentQuestion) return null;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column">
|
||||
<Box marginBottom={1}>
|
||||
<Text color={colors.approval.header}>
|
||||
<Text bold>[{currentQuestion.header}]</Text>{" "}
|
||||
{currentQuestion.question}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{questions.length > 1 && (
|
||||
<Box marginBottom={1}>
|
||||
<Text dimColor>
|
||||
Question {currentQuestionIndex + 1} of {questions.length}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{isOtherMode ? (
|
||||
<Box flexDirection="column">
|
||||
<Text dimColor>Type your response (Esc to cancel):</Text>
|
||||
<Box marginTop={1}>
|
||||
<Text color={colors.approval.header}>> </Text>
|
||||
<PasteAwareTextInput
|
||||
value={otherText}
|
||||
onChange={setOtherText}
|
||||
onSubmit={handleOtherSubmit}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
) : (
|
||||
<Box flexDirection="column">
|
||||
{optionsWithOther.map((option, index) => {
|
||||
const isSelected = index === selectedOption;
|
||||
const isChecked = selectedMulti.has(index);
|
||||
const color = isSelected ? colors.approval.header : undefined;
|
||||
|
||||
return (
|
||||
<Box
|
||||
key={option.label}
|
||||
flexDirection="column"
|
||||
marginBottom={index < optionsWithOther.length - 1 ? 1 : 0}
|
||||
>
|
||||
<Box flexDirection="row">
|
||||
<Box width={2} flexShrink={0}>
|
||||
<Text color={color}>{isSelected ? ">" : " "}</Text>
|
||||
</Box>
|
||||
{currentQuestion.multiSelect &&
|
||||
index < optionsWithOther.length - 1 && (
|
||||
<Box width={4} flexShrink={0}>
|
||||
<Text color={color}>[{isChecked ? "x" : " "}]</Text>
|
||||
</Box>
|
||||
)}
|
||||
<Box flexGrow={1}>
|
||||
<Text color={color} bold={isSelected}>
|
||||
{index + 1}. {option.label}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
{option.description && (
|
||||
<Box paddingLeft={currentQuestion.multiSelect ? 6 : 2}>
|
||||
<Text dimColor>{option.description}</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>
|
||||
{currentQuestion.multiSelect
|
||||
? "Space to toggle, Enter to confirm selection"
|
||||
: `Enter to select, or type 1-${optionsWithOther.length}`}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
QuestionDialog.displayName = "QuestionDialog";
|
||||
@@ -24,6 +24,12 @@ import {
|
||||
} from "./agent/context";
|
||||
import { createAgent } from "./agent/create";
|
||||
import { ensureSkillsBlocks, ISOLATED_BLOCK_LABELS } from "./agent/memory";
|
||||
import {
|
||||
ensureMemoryFilesystemBlock,
|
||||
formatMemorySyncSummary,
|
||||
syncMemoryFilesystem,
|
||||
updateMemoryFilesystemBlock,
|
||||
} from "./agent/memoryFilesystem";
|
||||
import { sendMessageStream } from "./agent/message";
|
||||
import { getModelUpdateArgs } from "./agent/model";
|
||||
import { SessionStats } from "./agent/stats";
|
||||
@@ -580,6 +586,36 @@ export async function handleHeadlessCommand(
|
||||
}
|
||||
}
|
||||
|
||||
// Sync filesystem-backed memory before creating conversations (only if memfs is enabled)
|
||||
if (settingsManager.isMemfsEnabled(agent.id)) {
|
||||
try {
|
||||
await ensureMemoryFilesystemBlock(agent.id);
|
||||
const syncResult = await syncMemoryFilesystem(agent.id);
|
||||
if (syncResult.conflicts.length > 0) {
|
||||
console.error(
|
||||
`Memory filesystem sync conflicts detected (${syncResult.conflicts.length}). Run in interactive mode to resolve.`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
await updateMemoryFilesystemBlock(agent.id);
|
||||
if (
|
||||
syncResult.updatedBlocks.length > 0 ||
|
||||
syncResult.createdBlocks.length > 0 ||
|
||||
syncResult.deletedBlocks.length > 0 ||
|
||||
syncResult.updatedFiles.length > 0 ||
|
||||
syncResult.createdFiles.length > 0 ||
|
||||
syncResult.deletedFiles.length > 0
|
||||
) {
|
||||
console.log(formatMemorySyncSummary(syncResult));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Memory filesystem sync failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Determine which blocks to isolate for the conversation
|
||||
let isolatedBlockLabels: string[] = [];
|
||||
if (!noSkillsFlag) {
|
||||
|
||||
@@ -24,6 +24,17 @@ export interface SessionRef {
|
||||
conversationId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-agent settings stored in a flat array.
|
||||
* baseUrl is omitted/undefined for Letta API (api.letta.com).
|
||||
*/
|
||||
export interface AgentSettings {
|
||||
agentId: string;
|
||||
baseUrl?: string; // undefined = Letta API (api.letta.com)
|
||||
pinned?: boolean; // true if agent is pinned
|
||||
memfs?: boolean; // true if memory filesystem is enabled
|
||||
}
|
||||
|
||||
export interface Settings {
|
||||
lastAgent: string | null; // DEPRECATED: kept for migration to lastSession
|
||||
lastSession?: SessionRef; // DEPRECATED: kept for backwards compat, use sessionsByServer
|
||||
@@ -40,7 +51,9 @@ export interface Settings {
|
||||
env?: Record<string, string>;
|
||||
// Server-indexed settings (agent IDs are server-specific)
|
||||
sessionsByServer?: Record<string, SessionRef>; // key = normalized base URL (e.g., "api.letta.com", "localhost:8283")
|
||||
pinnedAgentsByServer?: Record<string, string[]>; // key = normalized base URL
|
||||
pinnedAgentsByServer?: Record<string, string[]>; // DEPRECATED: use agents array
|
||||
// Unified agent settings array (replaces pinnedAgentsByServer)
|
||||
agents?: AgentSettings[];
|
||||
// Letta Cloud OAuth token management (stored separately in secrets)
|
||||
refreshToken?: string; // DEPRECATED: kept for migration, now stored in secrets
|
||||
tokenExpiresAt?: number; // Unix timestamp in milliseconds
|
||||
@@ -160,6 +173,9 @@ class SettingsManager {
|
||||
|
||||
// Migrate tokens to secrets if they exist in settings
|
||||
await this.migrateTokensToSecrets();
|
||||
|
||||
// Migrate pinnedAgents/pinnedAgentsByServer to agents array
|
||||
this.migrateToAgentsArray();
|
||||
} catch (error) {
|
||||
console.error("Error loading settings, using defaults:", error);
|
||||
this.settings = { ...DEFAULT_SETTINGS };
|
||||
@@ -168,6 +184,7 @@ class SettingsManager {
|
||||
// Still check secrets support and try to migrate in case of partial failure
|
||||
await this.checkSecretsSupport();
|
||||
await this.migrateTokensToSecrets();
|
||||
this.migrateToAgentsArray();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -249,6 +266,58 @@ class SettingsManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate from legacy pinnedAgents/pinnedAgentsByServer to unified agents array.
|
||||
* Runs on initialize if agents array doesn't exist yet.
|
||||
*/
|
||||
private migrateToAgentsArray(): void {
|
||||
if (!this.settings) return;
|
||||
if (this.settings.agents) return; // Already migrated
|
||||
|
||||
const agents: AgentSettings[] = [];
|
||||
const seen = new Set<string>(); // agentId+baseUrl dedup key
|
||||
|
||||
// Migrate from pinnedAgentsByServer (newest legacy format)
|
||||
if (this.settings.pinnedAgentsByServer) {
|
||||
for (const [serverKey, agentIds] of Object.entries(
|
||||
this.settings.pinnedAgentsByServer,
|
||||
)) {
|
||||
for (const agentId of agentIds) {
|
||||
// Normalize baseUrl: api.letta.com -> undefined
|
||||
const baseUrl = serverKey === "api.letta.com" ? undefined : serverKey;
|
||||
const key = `${agentId}@${baseUrl ?? "cloud"}`;
|
||||
if (!seen.has(key)) {
|
||||
agents.push({
|
||||
agentId,
|
||||
baseUrl,
|
||||
pinned: true,
|
||||
});
|
||||
seen.add(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Migrate from pinnedAgents (oldest legacy format - assumes Letta API)
|
||||
if (this.settings.pinnedAgents) {
|
||||
for (const agentId of this.settings.pinnedAgents) {
|
||||
const key = `${agentId}@cloud`;
|
||||
if (!seen.has(key)) {
|
||||
agents.push({ agentId, pinned: true });
|
||||
seen.add(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (agents.length > 0) {
|
||||
this.settings = { ...this.settings, agents };
|
||||
// Persist the migration (async, fire-and-forget)
|
||||
this.persistSettings().catch((error) => {
|
||||
console.warn("Failed to persist agents array migration:", error);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all settings (synchronous, from memory)
|
||||
* Note: Does not include secure tokens (API key, refresh token) from secrets
|
||||
@@ -1161,6 +1230,91 @@ class SettingsManager {
|
||||
console.warn("unpinProfile is deprecated, use unpinLocal(agentId) instead");
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// Agent Settings (unified agents array) Helpers
|
||||
// =====================================================================
|
||||
|
||||
/**
|
||||
* Get settings for a specific agent on the current server.
|
||||
* Returns undefined if agent not found in settings.
|
||||
*/
|
||||
private getAgentSettings(agentId: string): AgentSettings | undefined {
|
||||
const settings = this.getSettings();
|
||||
const serverKey = getCurrentServerKey(settings);
|
||||
const normalizedBaseUrl =
|
||||
serverKey === "api.letta.com" ? undefined : serverKey;
|
||||
|
||||
return settings.agents?.find(
|
||||
(a) =>
|
||||
a.agentId === agentId && (a.baseUrl ?? undefined) === normalizedBaseUrl,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create or update settings for a specific agent on the current server.
|
||||
*/
|
||||
private upsertAgentSettings(
|
||||
agentId: string,
|
||||
updates: Partial<Omit<AgentSettings, "agentId" | "baseUrl">>,
|
||||
): void {
|
||||
const settings = this.getSettings();
|
||||
const serverKey = getCurrentServerKey(settings);
|
||||
const normalizedBaseUrl =
|
||||
serverKey === "api.letta.com" ? undefined : serverKey;
|
||||
|
||||
const agents = [...(settings.agents || [])];
|
||||
const idx = agents.findIndex(
|
||||
(a) =>
|
||||
a.agentId === agentId && (a.baseUrl ?? undefined) === normalizedBaseUrl,
|
||||
);
|
||||
|
||||
if (idx >= 0) {
|
||||
// Update existing (idx >= 0 guarantees this exists)
|
||||
const existing = agents[idx] as AgentSettings;
|
||||
const updated: AgentSettings = {
|
||||
agentId: existing.agentId,
|
||||
baseUrl: existing.baseUrl,
|
||||
// Use nullish coalescing for pinned (undefined = keep existing)
|
||||
pinned: updates.pinned !== undefined ? updates.pinned : existing.pinned,
|
||||
// Use nullish coalescing for memfs (undefined = keep existing)
|
||||
memfs: updates.memfs !== undefined ? updates.memfs : existing.memfs,
|
||||
};
|
||||
// Clean up undefined/false values
|
||||
if (!updated.pinned) delete updated.pinned;
|
||||
if (!updated.memfs) delete updated.memfs;
|
||||
if (!updated.baseUrl) delete updated.baseUrl;
|
||||
agents[idx] = updated;
|
||||
} else {
|
||||
// Create new
|
||||
const newAgent: AgentSettings = {
|
||||
agentId,
|
||||
baseUrl: normalizedBaseUrl,
|
||||
...updates,
|
||||
};
|
||||
// Clean up undefined/false values
|
||||
if (!newAgent.pinned) delete newAgent.pinned;
|
||||
if (!newAgent.memfs) delete newAgent.memfs;
|
||||
if (!newAgent.baseUrl) delete newAgent.baseUrl;
|
||||
agents.push(newAgent);
|
||||
}
|
||||
|
||||
this.updateSettings({ agents });
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if memory filesystem is enabled for an agent on the current server.
|
||||
*/
|
||||
isMemfsEnabled(agentId: string): boolean {
|
||||
return this.getAgentSettings(agentId)?.memfs === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable or disable memory filesystem for an agent on the current server.
|
||||
*/
|
||||
setMemfsEnabled(agentId: string, enabled: boolean): void {
|
||||
this.upsertAgentSettings(agentId, { memfs: enabled });
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if local .letta directory exists (indicates existing project)
|
||||
*/
|
||||
|
||||
@@ -759,3 +759,165 @@ describe("Settings Manager - Edge Cases", () => {
|
||||
expect(settings.lastAgent).toBe("agent-2"); // Updated
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Agents Array Migration Tests
|
||||
// ============================================================================
|
||||
|
||||
describe("Settings Manager - Agents Array Migration", () => {
|
||||
test("Migrates from pinnedAgents (oldest legacy format)", async () => {
|
||||
// Setup: Write old format to disk
|
||||
const { writeFile, mkdir } = await import("../utils/fs.js");
|
||||
const settingsDir = join(testHomeDir, ".letta");
|
||||
await mkdir(settingsDir, { recursive: true });
|
||||
await writeFile(
|
||||
join(settingsDir, "settings.json"),
|
||||
JSON.stringify({
|
||||
pinnedAgents: ["agent-old-1", "agent-old-2"],
|
||||
tokenStreaming: true,
|
||||
}),
|
||||
);
|
||||
|
||||
await settingsManager.initialize();
|
||||
const settings = settingsManager.getSettings();
|
||||
|
||||
// Should have migrated to agents array
|
||||
expect(settings.agents).toBeDefined();
|
||||
expect(settings.agents).toHaveLength(2);
|
||||
expect(settings.agents?.[0]).toEqual({
|
||||
agentId: "agent-old-1",
|
||||
pinned: true,
|
||||
});
|
||||
expect(settings.agents?.[1]).toEqual({
|
||||
agentId: "agent-old-2",
|
||||
pinned: true,
|
||||
});
|
||||
// Legacy field should still exist for downgrade compat
|
||||
expect(settings.pinnedAgents).toEqual(["agent-old-1", "agent-old-2"]);
|
||||
});
|
||||
|
||||
test("Migrates from pinnedAgentsByServer (newer legacy format)", async () => {
|
||||
const { writeFile, mkdir } = await import("../utils/fs.js");
|
||||
const settingsDir = join(testHomeDir, ".letta");
|
||||
await mkdir(settingsDir, { recursive: true });
|
||||
await writeFile(
|
||||
join(settingsDir, "settings.json"),
|
||||
JSON.stringify({
|
||||
pinnedAgentsByServer: {
|
||||
"api.letta.com": ["agent-cloud-1"],
|
||||
"localhost:8283": ["agent-local-1", "agent-local-2"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await settingsManager.initialize();
|
||||
const settings = settingsManager.getSettings();
|
||||
|
||||
expect(settings.agents).toHaveLength(3);
|
||||
// Cloud agents have no baseUrl (or undefined)
|
||||
expect(settings.agents).toContainEqual({
|
||||
agentId: "agent-cloud-1",
|
||||
pinned: true,
|
||||
});
|
||||
// Local agents have baseUrl
|
||||
expect(settings.agents).toContainEqual({
|
||||
agentId: "agent-local-1",
|
||||
baseUrl: "localhost:8283",
|
||||
pinned: true,
|
||||
});
|
||||
expect(settings.agents).toContainEqual({
|
||||
agentId: "agent-local-2",
|
||||
baseUrl: "localhost:8283",
|
||||
pinned: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("Migrates from both legacy formats (deduplicated)", async () => {
|
||||
const { writeFile, mkdir } = await import("../utils/fs.js");
|
||||
const settingsDir = join(testHomeDir, ".letta");
|
||||
await mkdir(settingsDir, { recursive: true });
|
||||
await writeFile(
|
||||
join(settingsDir, "settings.json"),
|
||||
JSON.stringify({
|
||||
pinnedAgents: ["agent-1", "agent-2"], // Old old format
|
||||
pinnedAgentsByServer: {
|
||||
"api.letta.com": ["agent-1", "agent-3"], // agent-1 is duplicate
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await settingsManager.initialize();
|
||||
const settings = settingsManager.getSettings();
|
||||
|
||||
// Should have 3 agents (agent-1 deduped)
|
||||
expect(settings.agents).toHaveLength(3);
|
||||
const agentIds = settings.agents?.map((a) => a.agentId);
|
||||
expect(agentIds).toContain("agent-1");
|
||||
expect(agentIds).toContain("agent-2");
|
||||
expect(agentIds).toContain("agent-3");
|
||||
});
|
||||
|
||||
test("Already migrated settings are not re-migrated", async () => {
|
||||
const { writeFile, mkdir } = await import("../utils/fs.js");
|
||||
const settingsDir = join(testHomeDir, ".letta");
|
||||
await mkdir(settingsDir, { recursive: true });
|
||||
await writeFile(
|
||||
join(settingsDir, "settings.json"),
|
||||
JSON.stringify({
|
||||
agents: [{ agentId: "agent-new", pinned: true, memfs: true }],
|
||||
pinnedAgentsByServer: {
|
||||
"api.letta.com": ["agent-old"], // Should be ignored since agents exists
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await settingsManager.initialize();
|
||||
const settings = settingsManager.getSettings();
|
||||
|
||||
// Should only have the new format agent
|
||||
expect(settings.agents).toHaveLength(1);
|
||||
expect(settings.agents?.[0]?.agentId).toBe("agent-new");
|
||||
expect(settings.agents?.[0]?.memfs).toBe(true);
|
||||
});
|
||||
|
||||
test("isMemfsEnabled returns false for agents without memfs flag", async () => {
|
||||
await settingsManager.initialize();
|
||||
|
||||
// Manually set up agents array
|
||||
settingsManager.updateSettings({
|
||||
agents: [
|
||||
{ agentId: "agent-with-memfs", pinned: true, memfs: true },
|
||||
{ agentId: "agent-without-memfs", pinned: true },
|
||||
],
|
||||
});
|
||||
|
||||
expect(settingsManager.isMemfsEnabled("agent-with-memfs")).toBe(true);
|
||||
expect(settingsManager.isMemfsEnabled("agent-without-memfs")).toBe(false);
|
||||
expect(settingsManager.isMemfsEnabled("agent-unknown")).toBe(false);
|
||||
});
|
||||
|
||||
test("setMemfsEnabled adds/removes memfs flag", async () => {
|
||||
await settingsManager.initialize();
|
||||
|
||||
settingsManager.setMemfsEnabled("agent-test", true);
|
||||
expect(settingsManager.isMemfsEnabled("agent-test")).toBe(true);
|
||||
|
||||
settingsManager.setMemfsEnabled("agent-test", false);
|
||||
expect(settingsManager.isMemfsEnabled("agent-test")).toBe(false);
|
||||
});
|
||||
|
||||
test("setMemfsEnabled persists to disk", async () => {
|
||||
await settingsManager.initialize();
|
||||
|
||||
settingsManager.setMemfsEnabled("agent-persist-test", true);
|
||||
|
||||
// Wait for async persist
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
// Reset and reload
|
||||
await settingsManager.reset();
|
||||
await settingsManager.initialize();
|
||||
|
||||
expect(settingsManager.isMemfsEnabled("agent-persist-test")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -112,6 +112,89 @@ export async function ensureCorrectMemoryTool(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detach all memory tools from an agent.
|
||||
* Used when enabling memfs (filesystem-backed memory).
|
||||
*
|
||||
* @param agentId - Agent to detach memory tools from
|
||||
* @returns true if any tools were detached
|
||||
*/
|
||||
export async function detachMemoryTools(agentId: string): Promise<boolean> {
|
||||
const client = await getClient();
|
||||
|
||||
try {
|
||||
const agentWithTools = await client.agents.retrieve(agentId, {
|
||||
include: ["agent.tools"],
|
||||
});
|
||||
const currentTools = agentWithTools.tools || [];
|
||||
|
||||
let detachedAny = false;
|
||||
for (const tool of currentTools) {
|
||||
if (tool.name === "memory" || tool.name === "memory_apply_patch") {
|
||||
if (tool.id) {
|
||||
await client.agents.tools.detach(tool.id, { agent_id: agentId });
|
||||
detachedAny = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return detachedAny;
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
`Warning: Failed to detach memory tools: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-attach the appropriate memory tool to an agent.
|
||||
* Used when disabling memfs (filesystem-backed memory).
|
||||
* Forces attachment even if agent had no memory tool before.
|
||||
*
|
||||
* @param agentId - Agent to attach memory tool to
|
||||
* @param modelIdentifier - Model handle to determine which memory tool to use
|
||||
*/
|
||||
export async function reattachMemoryTool(
|
||||
agentId: string,
|
||||
modelIdentifier: string,
|
||||
): Promise<void> {
|
||||
const resolvedModel = resolveModel(modelIdentifier) ?? modelIdentifier;
|
||||
const client = await getClient();
|
||||
const shouldUsePatch = isOpenAIModel(resolvedModel);
|
||||
|
||||
try {
|
||||
const agentWithTools = await client.agents.retrieve(agentId, {
|
||||
include: ["agent.tools"],
|
||||
});
|
||||
const currentTools = agentWithTools.tools || [];
|
||||
const mapByName = new Map(currentTools.map((t) => [t.name, t.id]));
|
||||
|
||||
// Determine which memory tool we want
|
||||
const desiredMemoryTool = shouldUsePatch ? "memory_apply_patch" : "memory";
|
||||
|
||||
// Already has the tool?
|
||||
if (mapByName.has(desiredMemoryTool)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the tool on the server
|
||||
const resp = await client.tools.list({ name: desiredMemoryTool });
|
||||
const toolId = resp.items[0]?.id;
|
||||
if (!toolId) {
|
||||
console.warn(`Memory tool "${desiredMemoryTool}" not found on server`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Attach it
|
||||
await client.agents.tools.attach(toolId, { agent_id: agentId });
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
`Warning: Failed to reattach memory tool: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Force switch to a specific toolset regardless of model.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user