feat(listen): memory tools (#1495)
Co-authored-by: Letta Code <noreply@letta.com>
This commit is contained in:
@@ -375,8 +375,11 @@ export async function applyMemfsFlags(
|
||||
export async function isLettaCloud(): Promise<boolean> {
|
||||
const { getServerUrl } = await import("./client");
|
||||
const serverUrl = getServerUrl();
|
||||
|
||||
return (
|
||||
serverUrl.includes("api.letta.com") || process.env.LETTA_MEMFS_LOCAL === "1"
|
||||
serverUrl.includes("api.letta.com") ||
|
||||
process.env.LETTA_MEMFS_LOCAL === "1" ||
|
||||
process.env.LETTA_API_KEY === "local-desktop"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
// 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<string, unknown> = {
|
||||
type: "list_in_directory_response",
|
||||
path: parsed.path,
|
||||
folders,
|
||||
hasMore: false,
|
||||
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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user