diff --git a/src/agent/memoryFilesystem.ts b/src/agent/memoryFilesystem.ts index ba2123d..e02bf02 100644 --- a/src/agent/memoryFilesystem.ts +++ b/src/agent/memoryFilesystem.ts @@ -375,8 +375,11 @@ export async function applyMemfsFlags( export async function isLettaCloud(): Promise { const { getServerUrl } = await import("./client"); const serverUrl = getServerUrl(); + return ( - serverUrl.includes("api.letta.com") || process.env.LETTA_MEMFS_LOCAL === "1" + serverUrl.includes("api.letta.com") || + process.env.LETTA_MEMFS_LOCAL === "1" || + process.env.LETTA_API_KEY === "local-desktop" ); } diff --git a/src/tools/impl/Memory.ts b/src/tools/impl/Memory.ts index eb2edc4..b0dddd5 100644 --- a/src/tools/impl/Memory.ts +++ b/src/tools/impl/Memory.ts @@ -269,6 +269,9 @@ export async function memory(args: MemoryArgs): Promise { }; } + // Emit memory_updated push event so web UI auto-refreshes + emitMemoryUpdated(affectedPaths); + return { message: `Memory ${command} applied and pushed (${commitResult.sha?.slice(0, 7) ?? "unknown"}).`, }; @@ -599,3 +602,36 @@ function requireString( } return value; } + +/** + * Emit a `memory_updated` push event over the WebSocket so the web UI + * can auto-refresh its memory index without polling. + */ +function emitMemoryUpdated(affectedPaths: string[]): void { + try { + // Lazy-import to avoid circular deps — this file is loaded before WS infra + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { getActiveRuntime } = + require("../../websocket/listener/runtime") as { + getActiveRuntime: () => { + socket: { readyState: number; send: (data: string) => void } | null; + } | null; + }; + + const runtime = getActiveRuntime(); + const socket = runtime?.socket; + if (!socket || socket.readyState !== 1 /* WebSocket.OPEN */) { + return; + } + + socket.send( + JSON.stringify({ + type: "memory_updated", + affected_paths: affectedPaths, + timestamp: Date.now(), + }), + ); + } catch { + // Best-effort — never break tool execution for a push event + } +} diff --git a/src/types/protocol_v2.ts b/src/types/protocol_v2.ts index 0294694..ed95d2a 100644 --- a/src/types/protocol_v2.ts +++ b/src/types/protocol_v2.ts @@ -136,6 +136,7 @@ export interface DeviceStatus { current_available_skills: AvailableSkillSummary[]; background_processes: BackgroundProcessSummary[]; pending_control_requests: PendingControlRequest[]; + memory_directory: string | null; } export type LoopStatus = @@ -395,10 +396,16 @@ export interface SearchFilesCommand { max_results?: number; } -export interface ListFoldersInDirectoryCommand { - type: "list_folders_in_directory"; - /** Absolute path to list folders in. */ +export interface ListInDirectoryCommand { + type: "list_in_directory"; + /** Absolute path to list entries in. */ path: string; + /** When true, response includes non-directory entries in `files`. */ + include_files?: boolean; + /** Max entries to return (folders + files combined). */ + limit?: number; + /** Number of entries to skip before returning. */ + offset?: number; } export interface ReadFileCommand { @@ -409,6 +416,22 @@ export interface ReadFileCommand { request_id: string; } +export interface ListMemoryCommand { + type: "list_memory"; + /** Echoed back in every response chunk for request correlation. */ + request_id: string; + /** The agent whose memory to list. */ + agent_id: string; +} + +export interface EnableMemfsCommand { + type: "enable_memfs"; + /** Echoed back in the response for request correlation. */ + request_id: string; + /** The agent to enable memfs for. */ + agent_id: string; +} + export type WsProtocolCommand = | InputCommand | ChangeDeviceStateCommand @@ -419,8 +442,10 @@ export type WsProtocolCommand = | TerminalResizeCommand | TerminalKillCommand | SearchFilesCommand - | ListFoldersInDirectoryCommand - | ReadFileCommand; + | ListInDirectoryCommand + | ReadFileCommand + | ListMemoryCommand + | EnableMemfsCommand; export type WsProtocolMessage = | DeviceStatusUpdateMessage diff --git a/src/websocket/listener/client.ts b/src/websocket/listener/client.ts index fb7c557..a6faae1 100644 --- a/src/websocket/listener/client.ts +++ b/src/websocket/listener/client.ts @@ -62,7 +62,9 @@ import { persistPermissionModeMapForRuntime, } from "./permissionMode"; import { - isListFoldersCommand, + isEnableMemfsCommand, + isListInDirectoryCommand, + isListMemoryCommand, isReadFileCommand, isSearchFilesCommand, parseServerMessage, @@ -1019,35 +1021,66 @@ async function connectWithRetry( return; } - // ── Folder listing (no runtime scope required) ──────────────────── - if (isListFoldersCommand(parsed)) { + // ── Directory listing (no runtime scope required) ────────────────── + if (isListInDirectoryCommand(parsed)) { void (async () => { try { const { readdir } = await import("node:fs/promises"); const entries = await readdir(parsed.path, { withFileTypes: true }); - const folders = entries - .filter((e) => e.isDirectory()) - .map((e) => e.name) - .sort(); - socket.send( - JSON.stringify({ - type: "list_folders_in_directory_response", - path: parsed.path, - folders, - hasMore: false, - success: true, - }), - ); + + // Filter out OS/VCS noise before sorting + const IGNORED_NAMES = new Set([ + ".DS_Store", + ".git", + ".gitignore", + "Thumbs.db", + ]); + const sortedEntries = entries + .filter((e) => !IGNORED_NAMES.has(e.name)) + .sort((a, b) => a.name.localeCompare(b.name)); + + const allFolders: string[] = []; + const allFiles: string[] = []; + for (const e of sortedEntries) { + if (e.isDirectory()) { + allFolders.push(e.name); + } else if (parsed.include_files) { + allFiles.push(e.name); + } + } + + const total = allFolders.length + allFiles.length; + const offset = parsed.offset ?? 0; + const limit = parsed.limit ?? total; + + // Paginate over the combined [folders, files] list + const combined = [...allFolders, ...allFiles]; + const page = combined.slice(offset, offset + limit); + const folders = page.filter((name) => allFolders.includes(name)); + const files = page.filter((name) => allFiles.includes(name)); + + const response: Record = { + type: "list_in_directory_response", + path: parsed.path, + folders, + hasMore: offset + limit < total, + total, + success: true, + }; + if (parsed.include_files) { + response.files = files; + } + socket.send(JSON.stringify(response)); } catch (err) { socket.send( JSON.stringify({ - type: "list_folders_in_directory_response", + type: "list_in_directory_response", path: parsed.path, folders: [], hasMore: false, success: false, error: - err instanceof Error ? err.message : "Failed to list folders", + err instanceof Error ? err.message : "Failed to list directory", }), ); } @@ -1086,6 +1119,152 @@ async function connectWithRetry( return; } + // ── Memory index (no runtime scope required) ───────────────────── + if (isListMemoryCommand(parsed)) { + void (async () => { + try { + const { getMemoryFilesystemRoot } = await import( + "../../agent/memoryFilesystem" + ); + const { scanMemoryFilesystem, getFileNodes, readFileContent } = + await import("../../agent/memoryScanner"); + const { parseFrontmatter } = await import("../../utils/frontmatter"); + + const { existsSync } = await import("node:fs"); + const { join } = await import("node:path"); + + const memoryRoot = getMemoryFilesystemRoot(parsed.agent_id); + + // If the memory directory doesn't have a git repo, memfs + // hasn't been initialized — tell the UI so it can show the + // enable button instead of an empty file list. + const memfsInitialized = existsSync(join(memoryRoot, ".git")); + + if (!memfsInitialized) { + socket.send( + JSON.stringify({ + type: "list_memory_response", + request_id: parsed.request_id, + entries: [], + done: true, + total: 0, + success: true, + memfs_initialized: false, + }), + ); + return; + } + + const treeNodes = scanMemoryFilesystem(memoryRoot); + const fileNodes = getFileNodes(treeNodes).filter((n) => + n.name.endsWith(".md"), + ); + + const CHUNK_SIZE = 5; + const total = fileNodes.length; + + for (let i = 0; i < total; i += CHUNK_SIZE) { + const chunk = fileNodes.slice(i, i + CHUNK_SIZE); + const entries = chunk.map((node) => { + const raw = readFileContent(node.fullPath); + const { frontmatter, body } = parseFrontmatter(raw); + const desc = frontmatter.description; + return { + relative_path: node.relativePath, + is_system: + node.relativePath.startsWith("system/") || + node.relativePath.startsWith("system\\"), + description: typeof desc === "string" ? desc : null, + content: body, + size: body.length, + }; + }); + + const done = i + CHUNK_SIZE >= total; + socket.send( + JSON.stringify({ + type: "list_memory_response", + request_id: parsed.request_id, + entries, + done, + total, + success: true, + memfs_initialized: true, + }), + ); + } + + // Edge case: no files at all (repo exists but empty) + if (total === 0) { + socket.send( + JSON.stringify({ + type: "list_memory_response", + request_id: parsed.request_id, + entries: [], + done: true, + total: 0, + success: true, + memfs_initialized: true, + }), + ); + } + } catch (err) { + socket.send( + JSON.stringify({ + type: "list_memory_response", + request_id: parsed.request_id, + entries: [], + done: true, + total: 0, + success: false, + error: + err instanceof Error ? err.message : "Failed to list memory", + }), + ); + } + })(); + return; + } + + // ── Enable memfs command ──────────────────────────────────────────── + if (isEnableMemfsCommand(parsed)) { + void (async () => { + try { + const { applyMemfsFlags } = await import( + "../../agent/memoryFilesystem" + ); + const result = await applyMemfsFlags(parsed.agent_id, true, false); + socket.send( + JSON.stringify({ + type: "enable_memfs_response", + request_id: parsed.request_id, + success: true, + memory_directory: result.memoryDir, + }), + ); + // Push memory_updated so the UI auto-refreshes its file list + socket.send( + JSON.stringify({ + type: "memory_updated", + affected_paths: ["*"], + timestamp: Date.now(), + }), + ); + } catch (err) { + socket.send( + JSON.stringify({ + type: "enable_memfs_response", + request_id: parsed.request_id, + success: false, + error: + err instanceof Error ? err.message : "Failed to enable memfs", + }), + ); + } + })(); + return; + } + // ── Terminal commands (no runtime scope required) ────────────────── if (parsed.type === "terminal_spawn") { handleTerminalSpawn( diff --git a/src/websocket/listener/protocol-inbound.ts b/src/websocket/listener/protocol-inbound.ts index 81c36d3..7760165 100644 --- a/src/websocket/listener/protocol-inbound.ts +++ b/src/websocket/listener/protocol-inbound.ts @@ -2,8 +2,10 @@ import type WebSocket from "ws"; import type { AbortMessageCommand, ChangeDeviceStateCommand, + EnableMemfsCommand, InputCommand, - ListFoldersInDirectoryCommand, + ListInDirectoryCommand, + ListMemoryCommand, ReadFileCommand, RuntimeScope, SearchFilesCommand, @@ -255,12 +257,12 @@ export function isSearchFilesCommand( ); } -export function isListFoldersCommand( +export function isListInDirectoryCommand( value: unknown, -): value is ListFoldersInDirectoryCommand { +): value is ListInDirectoryCommand { if (!value || typeof value !== "object") return false; const c = value as { type?: unknown; path?: unknown }; - return c.type === "list_folders_in_directory" && typeof c.path === "string"; + return c.type === "list_in_directory" && typeof c.path === "string"; } export function isReadFileCommand(value: unknown): value is ReadFileCommand { @@ -273,6 +275,38 @@ export function isReadFileCommand(value: unknown): value is ReadFileCommand { ); } +export function isListMemoryCommand( + value: unknown, +): value is ListMemoryCommand { + if (!value || typeof value !== "object") return false; + const c = value as { + type?: unknown; + request_id?: unknown; + agent_id?: unknown; + }; + return ( + c.type === "list_memory" && + typeof c.request_id === "string" && + typeof c.agent_id === "string" + ); +} + +export function isEnableMemfsCommand( + value: unknown, +): value is EnableMemfsCommand { + if (!value || typeof value !== "object") return false; + const c = value as { + type?: unknown; + request_id?: unknown; + agent_id?: unknown; + }; + return ( + c.type === "enable_memfs" && + typeof c.request_id === "string" && + typeof c.agent_id === "string" + ); +} + export function parseServerMessage( data: WebSocket.RawData, ): ParsedServerMessage | null { @@ -289,8 +323,10 @@ export function parseServerMessage( isTerminalResizeCommand(parsed) || isTerminalKillCommand(parsed) || isSearchFilesCommand(parsed) || - isListFoldersCommand(parsed) || - isReadFileCommand(parsed) + isListInDirectoryCommand(parsed) || + isReadFileCommand(parsed) || + isListMemoryCommand(parsed) || + isEnableMemfsCommand(parsed) ) { return parsed as WsProtocolCommand; } diff --git a/src/websocket/listener/protocol-outbound.ts b/src/websocket/listener/protocol-outbound.ts index db1ab9a..d4f86c7 100644 --- a/src/websocket/listener/protocol-outbound.ts +++ b/src/websocket/listener/protocol-outbound.ts @@ -1,5 +1,6 @@ import type { MessageCreate } from "@letta-ai/letta-client/resources/agents/agents"; import WebSocket from "ws"; +import { getMemoryFilesystemRoot } from "../../agent/memoryFilesystem"; import { permissionMode } from "../../permissions/mode"; import type { DequeuedBatch } from "../../queue/queueRuntime"; import { settingsManager } from "../../settings-manager"; @@ -101,6 +102,7 @@ export function buildDeviceStatus( current_available_skills: [], background_processes: [], pending_control_requests: [], + memory_directory: null, }; } const scope = getScopeForRuntime(runtime, params); @@ -145,6 +147,9 @@ export function buildDeviceStatus( current_available_skills: [], background_processes: [], pending_control_requests: getPendingControlRequests(listener, scope), + memory_directory: scopedAgentId + ? getMemoryFilesystemRoot(scopedAgentId) + : null, }; }