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> {
|
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"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
path: parsed.path,
|
]);
|
||||||
folders,
|
const sortedEntries = entries
|
||||||
hasMore: false,
|
.filter((e) => !IGNORED_NAMES.has(e.name))
|
||||||
success: true,
|
.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: offset + limit < total,
|
||||||
|
total,
|
||||||
|
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(
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user