feat(listen): memory tools (#1495)

Co-authored-by: Letta Code <noreply@letta.com>
This commit is contained in:
Shubham Naik
2026-03-23 22:57:37 -07:00
committed by GitHub
parent e604dcd94e
commit 455e67a9b9
6 changed files with 314 additions and 30 deletions

View File

@@ -375,8 +375,11 @@ export async function applyMemfsFlags(
export async function isLettaCloud(): Promise<boolean> { export async function isLettaCloud(): Promise<boolean> {
const { getServerUrl } = await import("./client"); const { getServerUrl } = await import("./client");
const serverUrl = getServerUrl(); const serverUrl = getServerUrl();
return ( 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"
); );
} }

View File

@@ -269,6 +269,9 @@ export async function memory(args: MemoryArgs): Promise<MemoryResult> {
}; };
} }
// Emit memory_updated push event so web UI auto-refreshes
emitMemoryUpdated(affectedPaths);
return { return {
message: `Memory ${command} applied and pushed (${commitResult.sha?.slice(0, 7) ?? "unknown"}).`, message: `Memory ${command} applied and pushed (${commitResult.sha?.slice(0, 7) ?? "unknown"}).`,
}; };
@@ -599,3 +602,36 @@ function requireString(
} }
return value; 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
}
}

View File

@@ -136,6 +136,7 @@ export interface DeviceStatus {
current_available_skills: AvailableSkillSummary[]; current_available_skills: AvailableSkillSummary[];
background_processes: BackgroundProcessSummary[]; background_processes: BackgroundProcessSummary[];
pending_control_requests: PendingControlRequest[]; pending_control_requests: PendingControlRequest[];
memory_directory: string | null;
} }
export type LoopStatus = export type LoopStatus =
@@ -395,10 +396,16 @@ export interface SearchFilesCommand {
max_results?: number; max_results?: number;
} }
export interface ListFoldersInDirectoryCommand { export interface ListInDirectoryCommand {
type: "list_folders_in_directory"; type: "list_in_directory";
/** Absolute path to list folders in. */ /** Absolute path to list entries in. */
path: string; 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 { export interface ReadFileCommand {
@@ -409,6 +416,22 @@ export interface ReadFileCommand {
request_id: string; 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 = export type WsProtocolCommand =
| InputCommand | InputCommand
| ChangeDeviceStateCommand | ChangeDeviceStateCommand
@@ -419,8 +442,10 @@ export type WsProtocolCommand =
| TerminalResizeCommand | TerminalResizeCommand
| TerminalKillCommand | TerminalKillCommand
| SearchFilesCommand | SearchFilesCommand
| ListFoldersInDirectoryCommand | ListInDirectoryCommand
| ReadFileCommand; | ReadFileCommand
| ListMemoryCommand
| EnableMemfsCommand;
export type WsProtocolMessage = export type WsProtocolMessage =
| DeviceStatusUpdateMessage | DeviceStatusUpdateMessage

View File

@@ -62,7 +62,9 @@ import {
persistPermissionModeMapForRuntime, persistPermissionModeMapForRuntime,
} from "./permissionMode"; } from "./permissionMode";
import { import {
isListFoldersCommand, isEnableMemfsCommand,
isListInDirectoryCommand,
isListMemoryCommand,
isReadFileCommand, isReadFileCommand,
isSearchFilesCommand, isSearchFilesCommand,
parseServerMessage, parseServerMessage,
@@ -1019,35 +1021,66 @@ async function connectWithRetry(
return; return;
} }
// ── Folder listing (no runtime scope required) ──────────────────── // ── Directory listing (no runtime scope required) ──────────────────
if (isListFoldersCommand(parsed)) { if (isListInDirectoryCommand(parsed)) {
void (async () => { void (async () => {
try { try {
const { readdir } = await import("node:fs/promises"); const { readdir } = await import("node:fs/promises");
const entries = await readdir(parsed.path, { withFileTypes: true }); const entries = await readdir(parsed.path, { withFileTypes: true });
const folders = entries
.filter((e) => e.isDirectory()) // Filter out OS/VCS noise before sorting
.map((e) => e.name) const IGNORED_NAMES = new Set([
.sort(); ".DS_Store",
socket.send( ".git",
JSON.stringify({ ".gitignore",
type: "list_folders_in_directory_response", "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<string, unknown> = {
type: "list_in_directory_response",
path: parsed.path, path: parsed.path,
folders, folders,
hasMore: false, hasMore: offset + limit < total,
total,
success: true, success: true,
}), };
); if (parsed.include_files) {
response.files = files;
}
socket.send(JSON.stringify(response));
} catch (err) { } catch (err) {
socket.send( socket.send(
JSON.stringify({ JSON.stringify({
type: "list_folders_in_directory_response", type: "list_in_directory_response",
path: parsed.path, path: parsed.path,
folders: [], folders: [],
hasMore: false, hasMore: false,
success: false, success: false,
error: 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; 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) ────────────────── // ── Terminal commands (no runtime scope required) ──────────────────
if (parsed.type === "terminal_spawn") { if (parsed.type === "terminal_spawn") {
handleTerminalSpawn( handleTerminalSpawn(

View File

@@ -2,8 +2,10 @@ import type WebSocket from "ws";
import type { import type {
AbortMessageCommand, AbortMessageCommand,
ChangeDeviceStateCommand, ChangeDeviceStateCommand,
EnableMemfsCommand,
InputCommand, InputCommand,
ListFoldersInDirectoryCommand, ListInDirectoryCommand,
ListMemoryCommand,
ReadFileCommand, ReadFileCommand,
RuntimeScope, RuntimeScope,
SearchFilesCommand, SearchFilesCommand,
@@ -255,12 +257,12 @@ export function isSearchFilesCommand(
); );
} }
export function isListFoldersCommand( export function isListInDirectoryCommand(
value: unknown, value: unknown,
): value is ListFoldersInDirectoryCommand { ): value is ListInDirectoryCommand {
if (!value || typeof value !== "object") return false; if (!value || typeof value !== "object") return false;
const c = value as { type?: unknown; path?: unknown }; 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 { 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( export function parseServerMessage(
data: WebSocket.RawData, data: WebSocket.RawData,
): ParsedServerMessage | null { ): ParsedServerMessage | null {
@@ -289,8 +323,10 @@ export function parseServerMessage(
isTerminalResizeCommand(parsed) || isTerminalResizeCommand(parsed) ||
isTerminalKillCommand(parsed) || isTerminalKillCommand(parsed) ||
isSearchFilesCommand(parsed) || isSearchFilesCommand(parsed) ||
isListFoldersCommand(parsed) || isListInDirectoryCommand(parsed) ||
isReadFileCommand(parsed) isReadFileCommand(parsed) ||
isListMemoryCommand(parsed) ||
isEnableMemfsCommand(parsed)
) { ) {
return parsed as WsProtocolCommand; return parsed as WsProtocolCommand;
} }

View File

@@ -1,5 +1,6 @@
import type { MessageCreate } from "@letta-ai/letta-client/resources/agents/agents"; import type { MessageCreate } from "@letta-ai/letta-client/resources/agents/agents";
import WebSocket from "ws"; import WebSocket from "ws";
import { getMemoryFilesystemRoot } from "../../agent/memoryFilesystem";
import { permissionMode } from "../../permissions/mode"; import { permissionMode } from "../../permissions/mode";
import type { DequeuedBatch } from "../../queue/queueRuntime"; import type { DequeuedBatch } from "../../queue/queueRuntime";
import { settingsManager } from "../../settings-manager"; import { settingsManager } from "../../settings-manager";
@@ -101,6 +102,7 @@ export function buildDeviceStatus(
current_available_skills: [], current_available_skills: [],
background_processes: [], background_processes: [],
pending_control_requests: [], pending_control_requests: [],
memory_directory: null,
}; };
} }
const scope = getScopeForRuntime(runtime, params); const scope = getScopeForRuntime(runtime, params);
@@ -145,6 +147,9 @@ export function buildDeviceStatus(
current_available_skills: [], current_available_skills: [],
background_processes: [], background_processes: [],
pending_control_requests: getPendingControlRequests(listener, scope), pending_control_requests: getPendingControlRequests(listener, scope),
memory_directory: scopedAgentId
? getMemoryFilesystemRoot(scopedAgentId)
: null,
}; };
} }