feat(web): add Memory Palace static viewer (#1061)
Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
8
LICENSE
8
LICENSE
@@ -188,3 +188,11 @@
|
|||||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
See the License for the specific language governing permissions and
|
See the License for the specific language governing permissions and
|
||||||
limitations under the License.
|
limitations under the License.
|
||||||
|
|
||||||
|
Brand Assets Exclusion
|
||||||
|
|
||||||
|
The Letta name, Letta Code name, logo, wordmark SVGs, and ASCII art
|
||||||
|
included in this repository are copyrighted assets of Letta, Inc.
|
||||||
|
and are not licensed under the Apache License, Version 2.0. These
|
||||||
|
assets may not be used in derivative works without written permission
|
||||||
|
from Letta, Inc.
|
||||||
|
|||||||
5
build.js
5
build.js
@@ -16,12 +16,12 @@ const __dirname = dirname(__filename);
|
|||||||
const pkg = JSON.parse(readFileSync(join(__dirname, "package.json"), "utf-8"));
|
const pkg = JSON.parse(readFileSync(join(__dirname, "package.json"), "utf-8"));
|
||||||
const version = pkg.version;
|
const version = pkg.version;
|
||||||
const useMagick = Bun.env.USE_MAGICK;
|
const useMagick = Bun.env.USE_MAGICK;
|
||||||
const features = []
|
const features = [];
|
||||||
|
|
||||||
console.log(`📦 Building Letta Code v${version}...`);
|
console.log(`📦 Building Letta Code v${version}...`);
|
||||||
if (useMagick) {
|
if (useMagick) {
|
||||||
console.log(`🪄 Using magick variant of imageResize...`);
|
console.log(`🪄 Using magick variant of imageResize...`);
|
||||||
features.push("USE_MAGICK")
|
features.push("USE_MAGICK");
|
||||||
}
|
}
|
||||||
|
|
||||||
await Bun.build({
|
await Bun.build({
|
||||||
@@ -44,6 +44,7 @@ await Bun.build({
|
|||||||
".md": "text",
|
".md": "text",
|
||||||
".mdx": "text",
|
".mdx": "text",
|
||||||
".txt": "text",
|
".txt": "text",
|
||||||
|
|
||||||
},
|
},
|
||||||
features: features,
|
features: features,
|
||||||
});
|
});
|
||||||
|
|||||||
108
src/agent/memoryScanner.ts
Normal file
108
src/agent/memoryScanner.ts
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
/**
|
||||||
|
* Shared memory filesystem scanner.
|
||||||
|
*
|
||||||
|
* Recursively scans the on-disk memory directory and returns a flat list of
|
||||||
|
* TreeNode objects that represent files and directories. Used by both the
|
||||||
|
* TUI MemfsTreeViewer and the web-based memory viewer generator.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { readdirSync, readFileSync, statSync } from "node:fs";
|
||||||
|
import { join, relative } from "node:path";
|
||||||
|
|
||||||
|
export interface TreeNode {
|
||||||
|
name: string; // Display name (e.g., "git.md" or "dev_workflow/")
|
||||||
|
relativePath: string; // Relative path from memory root
|
||||||
|
fullPath: string; // Full filesystem path
|
||||||
|
isDirectory: boolean;
|
||||||
|
depth: number;
|
||||||
|
isLast: boolean;
|
||||||
|
parentIsLast: boolean[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scan the memory filesystem directory and build tree nodes.
|
||||||
|
*/
|
||||||
|
export function scanMemoryFilesystem(memoryRoot: string): TreeNode[] {
|
||||||
|
const nodes: TreeNode[] = [];
|
||||||
|
|
||||||
|
const scanDir = (dir: string, depth: number, parentIsLast: boolean[]) => {
|
||||||
|
let entries: string[];
|
||||||
|
try {
|
||||||
|
entries = readdirSync(dir);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter out hidden files and state file
|
||||||
|
const filtered = entries.filter((name) => !name.startsWith("."));
|
||||||
|
|
||||||
|
// Sort: directories first, "system" always first among dirs, then alphabetically
|
||||||
|
const sorted = filtered.sort((a, b) => {
|
||||||
|
const aPath = join(dir, a);
|
||||||
|
const bPath = join(dir, b);
|
||||||
|
let aIsDir = false;
|
||||||
|
let bIsDir = false;
|
||||||
|
try {
|
||||||
|
aIsDir = statSync(aPath).isDirectory();
|
||||||
|
} catch {}
|
||||||
|
try {
|
||||||
|
bIsDir = statSync(bPath).isDirectory();
|
||||||
|
} catch {}
|
||||||
|
if (aIsDir !== bIsDir) return aIsDir ? -1 : 1;
|
||||||
|
// "system" directory comes first (only at root level, depth 0)
|
||||||
|
if (aIsDir && bIsDir && depth === 0) {
|
||||||
|
if (a === "system") return -1;
|
||||||
|
if (b === "system") return 1;
|
||||||
|
}
|
||||||
|
return a.localeCompare(b);
|
||||||
|
});
|
||||||
|
|
||||||
|
sorted.forEach((name, index) => {
|
||||||
|
const fullPath = join(dir, name);
|
||||||
|
let isDir = false;
|
||||||
|
try {
|
||||||
|
isDir = statSync(fullPath).isDirectory();
|
||||||
|
} catch {
|
||||||
|
return; // Skip if we can't stat
|
||||||
|
}
|
||||||
|
|
||||||
|
const relativePath = relative(memoryRoot, fullPath);
|
||||||
|
const isLast = index === sorted.length - 1;
|
||||||
|
|
||||||
|
nodes.push({
|
||||||
|
name: isDir ? `${name}/` : name,
|
||||||
|
relativePath,
|
||||||
|
fullPath,
|
||||||
|
isDirectory: isDir,
|
||||||
|
depth,
|
||||||
|
isLast,
|
||||||
|
parentIsLast: [...parentIsLast],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isDir) {
|
||||||
|
scanDir(fullPath, depth + 1, [...parentIsLast, isLast]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
scanDir(memoryRoot, 0, []);
|
||||||
|
return nodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get only file nodes (for navigation).
|
||||||
|
*/
|
||||||
|
export function getFileNodes(nodes: TreeNode[]): TreeNode[] {
|
||||||
|
return nodes.filter((n) => !n.isDirectory);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read file content safely, returning empty string on failure.
|
||||||
|
*/
|
||||||
|
export function readFileContent(fullPath: string): string {
|
||||||
|
try {
|
||||||
|
return readFileSync(fullPath, "utf-8");
|
||||||
|
} catch {
|
||||||
|
return "(unable to read file)";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12564,6 +12564,7 @@ Plan file path: ${planFilePath}`;
|
|||||||
(settingsManager.isMemfsEnabled(agentId) ? (
|
(settingsManager.isMemfsEnabled(agentId) ? (
|
||||||
<MemfsTreeViewer
|
<MemfsTreeViewer
|
||||||
agentId={agentId}
|
agentId={agentId}
|
||||||
|
agentName={agentState?.name}
|
||||||
onClose={closeOverlay}
|
onClose={closeOverlay}
|
||||||
conversationId={conversationId}
|
conversationId={conversationId}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,9 +1,16 @@
|
|||||||
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
import { existsSync } from "node:fs";
|
||||||
import { join, relative } from "node:path";
|
|
||||||
import { Box, useInput } from "ink";
|
import { Box, useInput } from "ink";
|
||||||
import Link from "ink-link";
|
import Link from "ink-link";
|
||||||
import { useMemo, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { getMemoryFilesystemRoot } from "../../agent/memoryFilesystem";
|
import { getMemoryFilesystemRoot } from "../../agent/memoryFilesystem";
|
||||||
|
import { isGitRepo } from "../../agent/memoryGit";
|
||||||
|
import {
|
||||||
|
getFileNodes,
|
||||||
|
readFileContent,
|
||||||
|
scanMemoryFilesystem,
|
||||||
|
type TreeNode,
|
||||||
|
} from "../../agent/memoryScanner";
|
||||||
|
import { generateAndOpenMemoryViewer } from "../../web/generate-memory-viewer";
|
||||||
import { useTerminalWidth } from "../hooks/useTerminalWidth";
|
import { useTerminalWidth } from "../hooks/useTerminalWidth";
|
||||||
import { colors } from "./colors";
|
import { colors } from "./colors";
|
||||||
import { Text } from "./Text";
|
import { Text } from "./Text";
|
||||||
@@ -16,100 +23,13 @@ const DOTTED_LINE = "╌";
|
|||||||
const TREE_VISIBLE_LINES = 15;
|
const TREE_VISIBLE_LINES = 15;
|
||||||
const FULL_VIEW_VISIBLE_LINES = 16;
|
const FULL_VIEW_VISIBLE_LINES = 16;
|
||||||
|
|
||||||
// Tree structure types
|
|
||||||
interface TreeNode {
|
|
||||||
name: string; // Display name (e.g., "git.md" or "dev_workflow/")
|
|
||||||
relativePath: string; // Relative path from memory root
|
|
||||||
fullPath: string; // Full filesystem path
|
|
||||||
isDirectory: boolean;
|
|
||||||
depth: number;
|
|
||||||
isLast: boolean;
|
|
||||||
parentIsLast: boolean[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MemfsTreeViewerProps {
|
interface MemfsTreeViewerProps {
|
||||||
agentId: string;
|
agentId: string;
|
||||||
|
agentName?: string;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
conversationId?: string;
|
conversationId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Scan the memory filesystem directory and build tree nodes
|
|
||||||
*/
|
|
||||||
function scanMemoryFilesystem(memoryRoot: string): TreeNode[] {
|
|
||||||
const nodes: TreeNode[] = [];
|
|
||||||
|
|
||||||
const scanDir = (dir: string, depth: number, parentIsLast: boolean[]) => {
|
|
||||||
let entries: string[];
|
|
||||||
try {
|
|
||||||
entries = readdirSync(dir);
|
|
||||||
} catch {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter out hidden files and state file
|
|
||||||
const filtered = entries.filter((name) => !name.startsWith("."));
|
|
||||||
|
|
||||||
// Sort: directories first, "system" always first among dirs, then alphabetically
|
|
||||||
const sorted = filtered.sort((a, b) => {
|
|
||||||
const aPath = join(dir, a);
|
|
||||||
const bPath = join(dir, b);
|
|
||||||
let aIsDir = false;
|
|
||||||
let bIsDir = false;
|
|
||||||
try {
|
|
||||||
aIsDir = statSync(aPath).isDirectory();
|
|
||||||
} catch {}
|
|
||||||
try {
|
|
||||||
bIsDir = statSync(bPath).isDirectory();
|
|
||||||
} catch {}
|
|
||||||
if (aIsDir !== bIsDir) return aIsDir ? -1 : 1;
|
|
||||||
// "system" directory comes first (only at root level, depth 0)
|
|
||||||
if (aIsDir && bIsDir && depth === 0) {
|
|
||||||
if (a === "system") return -1;
|
|
||||||
if (b === "system") return 1;
|
|
||||||
}
|
|
||||||
return a.localeCompare(b);
|
|
||||||
});
|
|
||||||
|
|
||||||
sorted.forEach((name, index) => {
|
|
||||||
const fullPath = join(dir, name);
|
|
||||||
let isDir = false;
|
|
||||||
try {
|
|
||||||
isDir = statSync(fullPath).isDirectory();
|
|
||||||
} catch {
|
|
||||||
return; // Skip if we can't stat
|
|
||||||
}
|
|
||||||
|
|
||||||
const relativePath = relative(memoryRoot, fullPath);
|
|
||||||
const isLast = index === sorted.length - 1;
|
|
||||||
|
|
||||||
nodes.push({
|
|
||||||
name: isDir ? `${name}/` : name,
|
|
||||||
relativePath,
|
|
||||||
fullPath,
|
|
||||||
isDirectory: isDir,
|
|
||||||
depth,
|
|
||||||
isLast,
|
|
||||||
parentIsLast: [...parentIsLast],
|
|
||||||
});
|
|
||||||
|
|
||||||
if (isDir) {
|
|
||||||
scanDir(fullPath, depth + 1, [...parentIsLast, isLast]);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
scanDir(memoryRoot, 0, []);
|
|
||||||
return nodes;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get only file nodes (for navigation)
|
|
||||||
*/
|
|
||||||
function getFileNodes(nodes: TreeNode[]): TreeNode[] {
|
|
||||||
return nodes.filter((n) => !n.isDirectory);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Render tree line prefix based on depth and parent status
|
* Render tree line prefix based on depth and parent status
|
||||||
*/
|
*/
|
||||||
@@ -122,19 +42,9 @@ function renderTreePrefix(node: TreeNode): string {
|
|||||||
return prefix;
|
return prefix;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Read file content safely
|
|
||||||
*/
|
|
||||||
function readFileContent(fullPath: string): string {
|
|
||||||
try {
|
|
||||||
return readFileSync(fullPath, "utf-8");
|
|
||||||
} catch {
|
|
||||||
return "(unable to read file)";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function MemfsTreeViewer({
|
export function MemfsTreeViewer({
|
||||||
agentId,
|
agentId,
|
||||||
|
agentName,
|
||||||
onClose,
|
onClose,
|
||||||
conversationId,
|
conversationId,
|
||||||
}: MemfsTreeViewerProps) {
|
}: MemfsTreeViewerProps) {
|
||||||
@@ -148,10 +58,27 @@ export function MemfsTreeViewer({
|
|||||||
const [treeScrollOffset, setTreeScrollOffset] = useState(0);
|
const [treeScrollOffset, setTreeScrollOffset] = useState(0);
|
||||||
const [viewMode, setViewMode] = useState<"split" | "full">("split");
|
const [viewMode, setViewMode] = useState<"split" | "full">("split");
|
||||||
const [fullViewScrollOffset, setFullViewScrollOffset] = useState(0);
|
const [fullViewScrollOffset, setFullViewScrollOffset] = useState(0);
|
||||||
|
const [status, setStatus] = useState<string | null>(null);
|
||||||
|
const statusTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
// Get memory filesystem root
|
// Get memory filesystem root
|
||||||
const memoryRoot = getMemoryFilesystemRoot(agentId);
|
const memoryRoot = getMemoryFilesystemRoot(agentId);
|
||||||
const memoryExists = existsSync(memoryRoot);
|
const memoryExists = existsSync(memoryRoot);
|
||||||
|
const hasGitRepo = useMemo(() => isGitRepo(agentId), [agentId]);
|
||||||
|
|
||||||
|
function showStatus(msg: string, durationMs: number) {
|
||||||
|
if (statusTimerRef.current) clearTimeout(statusTimerRef.current);
|
||||||
|
setStatus(msg);
|
||||||
|
statusTimerRef.current = setTimeout(() => setStatus(null), durationMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup status timer on unmount
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
if (statusTimerRef.current) clearTimeout(statusTimerRef.current);
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
// Scan filesystem and build tree
|
// Scan filesystem and build tree
|
||||||
const treeNodes = useMemo(
|
const treeNodes = useMemo(
|
||||||
@@ -182,6 +109,20 @@ export function MemfsTreeViewer({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// O: open memory viewer in browser (works in both split and full view)
|
||||||
|
if ((input === "o" || input === "O") && hasGitRepo) {
|
||||||
|
showStatus("Opening in browser...", 10000);
|
||||||
|
generateAndOpenMemoryViewer(agentId, { agentName })
|
||||||
|
.then(() => showStatus("Opened in browser", 3000))
|
||||||
|
.catch((err: unknown) =>
|
||||||
|
showStatus(
|
||||||
|
err instanceof Error ? err.message : "Failed to open viewer",
|
||||||
|
5000,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// ESC: close or return from full view
|
// ESC: close or return from full view
|
||||||
if (key.escape) {
|
if (key.escape) {
|
||||||
if (viewMode === "full") {
|
if (viewMode === "full") {
|
||||||
@@ -349,7 +290,17 @@ export function MemfsTreeViewer({
|
|||||||
{" "}
|
{" "}
|
||||||
{charCount.toLocaleString()} chars
|
{charCount.toLocaleString()} chars
|
||||||
</Text>
|
</Text>
|
||||||
<Text dimColor>{" "}↑↓ scroll · Esc back</Text>
|
{status ? (
|
||||||
|
<Text dimColor>
|
||||||
|
{" "}
|
||||||
|
{status}
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<Text dimColor>
|
||||||
|
{" "}↑↓ scroll{hasGitRepo ? " · O open in browser" : ""} · Esc
|
||||||
|
back
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
@@ -507,6 +458,12 @@ export function MemfsTreeViewer({
|
|||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<Box flexDirection="column" marginTop={1}>
|
<Box flexDirection="column" marginTop={1}>
|
||||||
|
{status ? (
|
||||||
|
<Text dimColor>
|
||||||
|
{" "}
|
||||||
|
{status}
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
<Box>
|
<Box>
|
||||||
<Text dimColor>{" "}↑↓ navigate · Enter view · </Text>
|
<Text dimColor>{" "}↑↓ navigate · Enter view · </Text>
|
||||||
{!isTmux && (
|
{!isTmux && (
|
||||||
@@ -515,8 +472,10 @@ export function MemfsTreeViewer({
|
|||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
{isTmux && <Text dimColor>Edit in ADE: {adeUrl}</Text>}
|
{isTmux && <Text dimColor>Edit in ADE: {adeUrl}</Text>}
|
||||||
|
{hasGitRepo && <Text dimColor> · O open in browser</Text>}
|
||||||
<Text dimColor> · Esc close</Text>
|
<Text dimColor> · Esc close</Text>
|
||||||
</Box>
|
</Box>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
|||||||
458
src/web/generate-memory-viewer.ts
Normal file
458
src/web/generate-memory-viewer.ts
Normal file
@@ -0,0 +1,458 @@
|
|||||||
|
/**
|
||||||
|
* Memory Viewer Generator
|
||||||
|
*
|
||||||
|
* Collects data from the git-backed memory filesystem, injects it into the
|
||||||
|
* self-contained HTML template, writes the result to ~/.letta/viewers/, and
|
||||||
|
* opens it in the user's browser.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { execFile as execFileCb } from "node:child_process";
|
||||||
|
import { chmodSync, existsSync, mkdirSync, writeFileSync } from "node:fs";
|
||||||
|
import { homedir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { promisify } from "node:util";
|
||||||
|
import { getClient, getServerUrl } from "../agent/client";
|
||||||
|
import { getMemoryFilesystemRoot } from "../agent/memoryFilesystem";
|
||||||
|
import { getMemoryRepoDir, isGitRepo } from "../agent/memoryGit";
|
||||||
|
import {
|
||||||
|
getFileNodes,
|
||||||
|
readFileContent,
|
||||||
|
scanMemoryFilesystem,
|
||||||
|
} from "../agent/memoryScanner";
|
||||||
|
import memoryViewerTemplate from "./memory-viewer-template.txt";
|
||||||
|
import type {
|
||||||
|
ContextData,
|
||||||
|
MemoryCommit,
|
||||||
|
MemoryFile,
|
||||||
|
MemoryViewerData,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
const execFile = promisify(execFileCb);
|
||||||
|
|
||||||
|
const VIEWERS_DIR = join(homedir(), ".letta", "viewers");
|
||||||
|
const MAX_COMMITS = 500;
|
||||||
|
const RECENT_DIFF_COUNT = 50;
|
||||||
|
const PER_DIFF_CAP = 100_000; // 100KB per diff
|
||||||
|
const TOTAL_PAYLOAD_CAP = 5_000_000; // 5MB total
|
||||||
|
const RECORD_SEP = "\x1e";
|
||||||
|
|
||||||
|
export interface GenerateResult {
|
||||||
|
filePath: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Git helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function runGitSafe(cwd: string, args: string[]): Promise<string> {
|
||||||
|
try {
|
||||||
|
const { stdout } = await execFile("git", args, {
|
||||||
|
cwd,
|
||||||
|
maxBuffer: 10 * 1024 * 1024,
|
||||||
|
timeout: 60_000,
|
||||||
|
});
|
||||||
|
return stdout?.toString() ?? "";
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Data collectors
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Parse frontmatter from a .md file's raw content. */
|
||||||
|
function parseFrontmatter(raw: string): {
|
||||||
|
frontmatter: Record<string, string>;
|
||||||
|
body: string;
|
||||||
|
} {
|
||||||
|
if (!raw.startsWith("---")) {
|
||||||
|
return { frontmatter: {}, body: raw };
|
||||||
|
}
|
||||||
|
const closingIdx = raw.indexOf("\n---", 3);
|
||||||
|
if (closingIdx === -1) {
|
||||||
|
return { frontmatter: {}, body: raw };
|
||||||
|
}
|
||||||
|
const fmBlock = raw.slice(4, closingIdx);
|
||||||
|
const fm: Record<string, string> = {};
|
||||||
|
for (const line of fmBlock.split("\n")) {
|
||||||
|
const colonIdx = line.indexOf(":");
|
||||||
|
if (colonIdx > 0) {
|
||||||
|
const key = line.slice(0, colonIdx).trim();
|
||||||
|
const value = line.slice(colonIdx + 1).trim();
|
||||||
|
if (key) fm[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const body = raw.slice(closingIdx + 4).replace(/^\n/, "");
|
||||||
|
return { frontmatter: fm, body };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Collect memory files from the working tree on disk. */
|
||||||
|
function collectFiles(memoryRoot: string): MemoryFile[] {
|
||||||
|
const treeNodes = scanMemoryFilesystem(memoryRoot);
|
||||||
|
const fileNodes = getFileNodes(treeNodes);
|
||||||
|
|
||||||
|
return fileNodes
|
||||||
|
.filter((n) => n.name.endsWith(".md"))
|
||||||
|
.map((n) => {
|
||||||
|
const raw = readFileContent(n.fullPath);
|
||||||
|
const { frontmatter, body } = parseFrontmatter(raw);
|
||||||
|
return {
|
||||||
|
path: n.relativePath,
|
||||||
|
isSystem:
|
||||||
|
n.relativePath.startsWith("system/") ||
|
||||||
|
n.relativePath.startsWith("system\\"),
|
||||||
|
frontmatter,
|
||||||
|
content: body,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Collect commit metadata via a single git log call. */
|
||||||
|
async function collectMetadata(repoDir: string): Promise<
|
||||||
|
Array<{
|
||||||
|
hash: string;
|
||||||
|
author: string;
|
||||||
|
date: string;
|
||||||
|
subject: string;
|
||||||
|
body: string;
|
||||||
|
}>
|
||||||
|
> {
|
||||||
|
// Use RECORD_SEP between commits and NUL between fixed fields.
|
||||||
|
// Body (%b) can be empty, so we use exactly 4 NUL delimiters per record
|
||||||
|
// and treat everything after the 4th NUL (up to the next RECORD_SEP) as body.
|
||||||
|
const raw = await runGitSafe(repoDir, [
|
||||||
|
"log",
|
||||||
|
"-n",
|
||||||
|
String(MAX_COMMITS),
|
||||||
|
"--first-parent",
|
||||||
|
`--format=${RECORD_SEP}%H%x00%an%x00%aI%x00%s%x00%b`,
|
||||||
|
]);
|
||||||
|
if (!raw.trim()) return [];
|
||||||
|
|
||||||
|
const records = raw.split(RECORD_SEP).filter((s) => s.trim().length > 0);
|
||||||
|
const commits: Array<{
|
||||||
|
hash: string;
|
||||||
|
author: string;
|
||||||
|
date: string;
|
||||||
|
subject: string;
|
||||||
|
body: string;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
for (const record of records) {
|
||||||
|
const parts = record.replace(/^\n+/, "");
|
||||||
|
// Split on first 4 NUL bytes only
|
||||||
|
const nulPositions: number[] = [];
|
||||||
|
for (let j = 0; j < parts.length && nulPositions.length < 4; j++) {
|
||||||
|
if (parts[j] === "\0") nulPositions.push(j);
|
||||||
|
}
|
||||||
|
if (nulPositions.length < 4) continue;
|
||||||
|
const [p0, p1, p2, p3] = nulPositions as [number, number, number, number];
|
||||||
|
|
||||||
|
const hash = parts.slice(0, p0).trim();
|
||||||
|
const author = parts.slice(p0 + 1, p1).trim();
|
||||||
|
const date = parts.slice(p1 + 1, p2).trim();
|
||||||
|
const subject = parts.slice(p2 + 1, p3).trim();
|
||||||
|
const body = parts.slice(p3 + 1).trim();
|
||||||
|
|
||||||
|
if (!hash || !/^[0-9a-f]{40}$/i.test(hash)) continue;
|
||||||
|
|
||||||
|
commits.push({ hash, author, date, subject, body });
|
||||||
|
}
|
||||||
|
return commits;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Collect diffstats via a single git log call. Returns a hash -> stat map. */
|
||||||
|
async function collectStats(repoDir: string): Promise<Map<string, string>> {
|
||||||
|
const raw = await runGitSafe(repoDir, [
|
||||||
|
"log",
|
||||||
|
"-n",
|
||||||
|
String(MAX_COMMITS),
|
||||||
|
"--first-parent",
|
||||||
|
`--format=${RECORD_SEP}%H`,
|
||||||
|
"--stat",
|
||||||
|
]);
|
||||||
|
if (!raw.trim()) return new Map();
|
||||||
|
|
||||||
|
const map = new Map<string, string>();
|
||||||
|
const chunks = raw.split(RECORD_SEP).filter((s) => s.trim().length > 0);
|
||||||
|
for (const chunk of chunks) {
|
||||||
|
const normalized = chunk.replace(/^\n+/, "");
|
||||||
|
const firstNewline = normalized.indexOf("\n");
|
||||||
|
if (firstNewline === -1) continue;
|
||||||
|
const hash = normalized.slice(0, firstNewline).trim();
|
||||||
|
if (!/^[0-9a-f]{40}$/i.test(hash)) continue;
|
||||||
|
map.set(hash, normalized.slice(firstNewline + 1).trim());
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Collect full diffs for the most recent N commits. Returns hash -> patch map. */
|
||||||
|
async function collectDiffs(repoDir: string): Promise<Map<string, string>> {
|
||||||
|
const raw = await runGitSafe(repoDir, [
|
||||||
|
"log",
|
||||||
|
"-n",
|
||||||
|
String(RECENT_DIFF_COUNT),
|
||||||
|
"--first-parent",
|
||||||
|
`--format=${RECORD_SEP}%H`,
|
||||||
|
"-p",
|
||||||
|
]);
|
||||||
|
if (!raw.trim()) return new Map();
|
||||||
|
|
||||||
|
const map = new Map<string, string>();
|
||||||
|
const chunks = raw.split(RECORD_SEP).filter((s) => s.trim().length > 0);
|
||||||
|
for (const chunk of chunks) {
|
||||||
|
const normalized = chunk.replace(/^\n+/, "");
|
||||||
|
const firstNewline = normalized.indexOf("\n");
|
||||||
|
if (firstNewline === -1) continue;
|
||||||
|
const hash = normalized.slice(0, firstNewline).trim();
|
||||||
|
if (!/^[0-9a-f]{40}$/i.test(hash)) continue;
|
||||||
|
map.set(hash, normalized.slice(firstNewline + 1));
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get total commit count (may exceed MAX_COMMITS). */
|
||||||
|
async function getTotalCommitCount(repoDir: string): Promise<number> {
|
||||||
|
const raw = await runGitSafe(repoDir, ["rev-list", "--count", "HEAD"]);
|
||||||
|
const n = parseInt(raw.trim(), 10);
|
||||||
|
return Number.isNaN(n) ? 0 : n;
|
||||||
|
}
|
||||||
|
|
||||||
|
const REFLECTION_PATTERN = /\(reflection\)|🔮|reflection:/i;
|
||||||
|
|
||||||
|
/** Assemble all data into a MemoryViewerData object. */
|
||||||
|
async function collectMemoryData(
|
||||||
|
agentId: string,
|
||||||
|
repoDir: string,
|
||||||
|
memoryRoot: string,
|
||||||
|
): Promise<MemoryViewerData> {
|
||||||
|
// Filesystem scan (synchronous)
|
||||||
|
const files = collectFiles(memoryRoot);
|
||||||
|
|
||||||
|
// Git calls (parallel)
|
||||||
|
const [metadata, statsMap, diffsMap, totalCount] = await Promise.all([
|
||||||
|
collectMetadata(repoDir),
|
||||||
|
collectStats(repoDir),
|
||||||
|
collectDiffs(repoDir),
|
||||||
|
getTotalCommitCount(repoDir),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Merge into commits with payload size caps
|
||||||
|
let cumulativeSize = 0;
|
||||||
|
const commits: MemoryCommit[] = metadata.map((m) => {
|
||||||
|
const message = m.body ? `${m.subject}\n\n${m.body}` : m.subject;
|
||||||
|
const stats = statsMap.get(m.hash) ?? "";
|
||||||
|
let diff = diffsMap.get(m.hash);
|
||||||
|
let truncated = false;
|
||||||
|
|
||||||
|
if (diff !== undefined) {
|
||||||
|
if (diff.length > PER_DIFF_CAP) {
|
||||||
|
diff = `${diff.slice(0, PER_DIFF_CAP)}\n\n[diff truncated - exceeded ${Math.round(PER_DIFF_CAP / 1024)}KB]`;
|
||||||
|
truncated = true;
|
||||||
|
}
|
||||||
|
cumulativeSize += diff.length;
|
||||||
|
if (cumulativeSize > TOTAL_PAYLOAD_CAP) {
|
||||||
|
diff = undefined;
|
||||||
|
truncated = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
hash: m.hash,
|
||||||
|
shortHash: m.hash.slice(0, 7),
|
||||||
|
author: m.author,
|
||||||
|
date: m.date,
|
||||||
|
message,
|
||||||
|
stats,
|
||||||
|
diff,
|
||||||
|
truncated,
|
||||||
|
isReflection: REFLECTION_PATTERN.test(m.subject),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
let serverUrl: string;
|
||||||
|
try {
|
||||||
|
serverUrl = getServerUrl();
|
||||||
|
} catch {
|
||||||
|
serverUrl = process.env.LETTA_BASE_URL || "https://api.letta.com";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch agent info and context breakdown (best-effort, parallel)
|
||||||
|
let agentName = agentId;
|
||||||
|
let context: ContextData | undefined;
|
||||||
|
let model = "unknown";
|
||||||
|
|
||||||
|
// Try SDK client for agent name + model info
|
||||||
|
try {
|
||||||
|
const client = await getClient();
|
||||||
|
const agent = await client.agents.retrieve(agentId);
|
||||||
|
if (agent.name) agentName = agent.name;
|
||||||
|
model = agent.llm_config?.model ?? "unknown";
|
||||||
|
|
||||||
|
// Fetch context breakdown via raw API (not in SDK)
|
||||||
|
const apiKey =
|
||||||
|
(client as unknown as { apiKey?: string }).apiKey ||
|
||||||
|
process.env.LETTA_API_KEY ||
|
||||||
|
"";
|
||||||
|
const contextWindow = agent.llm_config?.context_window ?? 0;
|
||||||
|
try {
|
||||||
|
const contextRes = await fetch(
|
||||||
|
`${serverUrl}/v1/agents/${agentId}/context`,
|
||||||
|
{
|
||||||
|
headers: { Authorization: `Bearer ${apiKey}` },
|
||||||
|
signal: AbortSignal.timeout(5000),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (contextRes.ok) {
|
||||||
|
const overview = (await contextRes.json()) as {
|
||||||
|
context_window_size_max: number;
|
||||||
|
context_window_size_current: number;
|
||||||
|
num_tokens_system: number;
|
||||||
|
num_tokens_core_memory: number;
|
||||||
|
num_tokens_external_memory_summary: number;
|
||||||
|
num_tokens_summary_memory: number;
|
||||||
|
num_tokens_functions_definitions: number;
|
||||||
|
num_tokens_messages: number;
|
||||||
|
};
|
||||||
|
context = {
|
||||||
|
contextWindow: contextWindow || overview.context_window_size_max,
|
||||||
|
usedTokens: overview.context_window_size_current,
|
||||||
|
model,
|
||||||
|
breakdown: {
|
||||||
|
system: overview.num_tokens_system,
|
||||||
|
coreMemory: overview.num_tokens_core_memory,
|
||||||
|
externalMemory: overview.num_tokens_external_memory_summary,
|
||||||
|
summaryMemory: overview.num_tokens_summary_memory,
|
||||||
|
tools: overview.num_tokens_functions_definitions,
|
||||||
|
messages: overview.num_tokens_messages,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Context fetch failed - continue without it
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// SDK client failed - try raw API with env key as fallback
|
||||||
|
try {
|
||||||
|
const apiKey = process.env.LETTA_API_KEY || "";
|
||||||
|
if (apiKey && serverUrl) {
|
||||||
|
// Fetch agent info + context in parallel
|
||||||
|
const [agentRes, contextRes] = await Promise.all([
|
||||||
|
fetch(`${serverUrl}/v1/agents/${agentId}`, {
|
||||||
|
headers: { Authorization: `Bearer ${apiKey}` },
|
||||||
|
signal: AbortSignal.timeout(5000),
|
||||||
|
}).catch(() => null),
|
||||||
|
fetch(`${serverUrl}/v1/agents/${agentId}/context`, {
|
||||||
|
headers: { Authorization: `Bearer ${apiKey}` },
|
||||||
|
signal: AbortSignal.timeout(5000),
|
||||||
|
}).catch(() => null),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (agentRes?.ok) {
|
||||||
|
const agentData = (await agentRes.json()) as {
|
||||||
|
name?: string;
|
||||||
|
llm_config?: { model?: string; context_window?: number };
|
||||||
|
};
|
||||||
|
if (agentData.name) agentName = agentData.name;
|
||||||
|
if (agentData.llm_config?.model) model = agentData.llm_config.model;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (contextRes?.ok) {
|
||||||
|
const overview = (await contextRes.json()) as {
|
||||||
|
context_window_size_max: number;
|
||||||
|
context_window_size_current: number;
|
||||||
|
num_tokens_system: number;
|
||||||
|
num_tokens_core_memory: number;
|
||||||
|
num_tokens_external_memory_summary: number;
|
||||||
|
num_tokens_summary_memory: number;
|
||||||
|
num_tokens_functions_definitions: number;
|
||||||
|
num_tokens_messages: number;
|
||||||
|
};
|
||||||
|
context = {
|
||||||
|
contextWindow: overview.context_window_size_max,
|
||||||
|
usedTokens: overview.context_window_size_current,
|
||||||
|
model,
|
||||||
|
breakdown: {
|
||||||
|
system: overview.num_tokens_system,
|
||||||
|
coreMemory: overview.num_tokens_core_memory,
|
||||||
|
externalMemory: overview.num_tokens_external_memory_summary,
|
||||||
|
summaryMemory: overview.num_tokens_summary_memory,
|
||||||
|
tools: overview.num_tokens_functions_definitions,
|
||||||
|
messages: overview.num_tokens_messages,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// All API calls failed - continue without context
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
agent: { id: agentId, name: agentName, serverUrl },
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
totalCommitCount: totalCount || commits.length,
|
||||||
|
files,
|
||||||
|
commits,
|
||||||
|
context,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Public API
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export async function generateAndOpenMemoryViewer(
|
||||||
|
agentId: string,
|
||||||
|
options?: { agentName?: string },
|
||||||
|
): Promise<GenerateResult> {
|
||||||
|
const repoDir = getMemoryRepoDir(agentId);
|
||||||
|
const memoryRoot = getMemoryFilesystemRoot(agentId);
|
||||||
|
|
||||||
|
if (!isGitRepo(agentId)) {
|
||||||
|
throw new Error("Memory viewer requires memfs. Run /memfs enable first.");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Collect data
|
||||||
|
const data = await collectMemoryData(agentId, repoDir, memoryRoot);
|
||||||
|
|
||||||
|
// Override agent name if provided by caller
|
||||||
|
if (options?.agentName) {
|
||||||
|
data.agent.name = options.agentName;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Safely embed JSON - escape < to \u003c to prevent </script> injection
|
||||||
|
const jsonPayload = JSON.stringify(data).replace(/</g, "\\u003c");
|
||||||
|
const html = memoryViewerTemplate.replace(
|
||||||
|
"<!--LETTA_DATA_PLACEHOLDER-->",
|
||||||
|
jsonPayload,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 3. Write to ~/.letta/viewers/ with owner-only permissions
|
||||||
|
if (!existsSync(VIEWERS_DIR)) {
|
||||||
|
mkdirSync(VIEWERS_DIR, { recursive: true, mode: 0o700 });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
chmodSync(VIEWERS_DIR, 0o700);
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
const filePath = join(
|
||||||
|
VIEWERS_DIR,
|
||||||
|
`memory-${encodeURIComponent(agentId)}.html`,
|
||||||
|
);
|
||||||
|
writeFileSync(filePath, html);
|
||||||
|
chmodSync(filePath, 0o600);
|
||||||
|
|
||||||
|
// 4. Open in browser
|
||||||
|
try {
|
||||||
|
const { default: openUrl } = await import("open");
|
||||||
|
await openUrl(filePath, { wait: false });
|
||||||
|
} catch (err) {
|
||||||
|
throw new Error(
|
||||||
|
`Failed to open browser. File saved to: ${filePath}${err instanceof Error ? ` (${err.message})` : ""}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { filePath };
|
||||||
|
}
|
||||||
4
src/web/html.d.ts
vendored
Normal file
4
src/web/html.d.ts
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
declare module "*memory-viewer-template.txt" {
|
||||||
|
const content: string;
|
||||||
|
export default content;
|
||||||
|
}
|
||||||
1627
src/web/memory-viewer-template.txt
Normal file
1627
src/web/memory-viewer-template.txt
Normal file
File diff suppressed because one or more lines are too long
41
src/web/types.ts
Normal file
41
src/web/types.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
export interface ContextData {
|
||||||
|
contextWindow: number; // max tokens
|
||||||
|
usedTokens: number; // current total
|
||||||
|
model: string;
|
||||||
|
breakdown: {
|
||||||
|
system: number;
|
||||||
|
coreMemory: number;
|
||||||
|
externalMemory: number;
|
||||||
|
summaryMemory: number;
|
||||||
|
tools: number;
|
||||||
|
messages: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MemoryViewerData {
|
||||||
|
agent: { id: string; name: string; serverUrl: string };
|
||||||
|
generatedAt: string; // ISO 8601 timestamp
|
||||||
|
totalCommitCount: number; // total commits in repo (may exceed commits.length)
|
||||||
|
files: MemoryFile[];
|
||||||
|
commits: MemoryCommit[];
|
||||||
|
context?: ContextData; // from GET /v1/agents/{id}/context
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MemoryFile {
|
||||||
|
path: string; // e.g. "system/persona/soul.md"
|
||||||
|
isSystem: boolean; // under system/ directory
|
||||||
|
frontmatter: Record<string, string>;
|
||||||
|
content: string; // raw markdown body (after frontmatter)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MemoryCommit {
|
||||||
|
hash: string;
|
||||||
|
shortHash: string;
|
||||||
|
author: string;
|
||||||
|
date: string; // ISO 8601
|
||||||
|
message: string;
|
||||||
|
stats: string; // diffstat summary
|
||||||
|
diff?: string; // full unified diff patch (only for recent N commits)
|
||||||
|
truncated?: boolean; // diff was truncated due to size cap
|
||||||
|
isReflection: boolean; // commit message matches reflection/sleeptime pattern
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user