From b622eca198357d9e9ecc2282d6fbdea4a58655ee Mon Sep 17 00:00:00 2001 From: Charles Packer Date: Fri, 20 Feb 2026 12:00:55 -0800 Subject: [PATCH] feat(web): add Memory Palace static viewer (#1061) Co-authored-by: Letta --- LICENSE | 8 + build.js | 5 +- src/agent/memoryScanner.ts | 108 ++ src/cli/App.tsx | 1 + src/cli/components/MemfsTreeViewer.tsx | 185 ++- src/web/generate-memory-viewer.ts | 458 +++++++ src/web/html.d.ts | 4 + src/web/memory-viewer-template.txt | 1627 ++++++++++++++++++++++++ src/web/types.ts | 41 + 9 files changed, 2322 insertions(+), 115 deletions(-) create mode 100644 src/agent/memoryScanner.ts create mode 100644 src/web/generate-memory-viewer.ts create mode 100644 src/web/html.d.ts create mode 100644 src/web/memory-viewer-template.txt create mode 100644 src/web/types.ts diff --git a/LICENSE b/LICENSE index f9839f4..3975f7d 100644 --- a/LICENSE +++ b/LICENSE @@ -188,3 +188,11 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and 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. diff --git a/build.js b/build.js index 4caeb46..3d330e2 100644 --- a/build.js +++ b/build.js @@ -16,12 +16,12 @@ const __dirname = dirname(__filename); const pkg = JSON.parse(readFileSync(join(__dirname, "package.json"), "utf-8")); const version = pkg.version; const useMagick = Bun.env.USE_MAGICK; -const features = [] +const features = []; console.log(`📦 Building Letta Code v${version}...`); if (useMagick) { console.log(`🪄 Using magick variant of imageResize...`); - features.push("USE_MAGICK") + features.push("USE_MAGICK"); } await Bun.build({ @@ -44,6 +44,7 @@ await Bun.build({ ".md": "text", ".mdx": "text", ".txt": "text", + }, features: features, }); diff --git a/src/agent/memoryScanner.ts b/src/agent/memoryScanner.ts new file mode 100644 index 0000000..06613b5 --- /dev/null +++ b/src/agent/memoryScanner.ts @@ -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)"; + } +} diff --git a/src/cli/App.tsx b/src/cli/App.tsx index 3f4b532..69fa10b 100644 --- a/src/cli/App.tsx +++ b/src/cli/App.tsx @@ -12564,6 +12564,7 @@ Plan file path: ${planFilePath}`; (settingsManager.isMemfsEnabled(agentId) ? ( diff --git a/src/cli/components/MemfsTreeViewer.tsx b/src/cli/components/MemfsTreeViewer.tsx index 15ec892..8992c9e 100644 --- a/src/cli/components/MemfsTreeViewer.tsx +++ b/src/cli/components/MemfsTreeViewer.tsx @@ -1,9 +1,16 @@ -import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; -import { join, relative } from "node:path"; +import { existsSync } from "node:fs"; import { Box, useInput } from "ink"; import Link from "ink-link"; -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; 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 { colors } from "./colors"; import { Text } from "./Text"; @@ -16,100 +23,13 @@ const DOTTED_LINE = "╌"; const TREE_VISIBLE_LINES = 15; 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 { agentId: string; + agentName?: string; onClose: () => void; 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 */ @@ -122,19 +42,9 @@ function renderTreePrefix(node: TreeNode): string { 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({ agentId, + agentName, onClose, conversationId, }: MemfsTreeViewerProps) { @@ -148,10 +58,27 @@ export function MemfsTreeViewer({ const [treeScrollOffset, setTreeScrollOffset] = useState(0); const [viewMode, setViewMode] = useState<"split" | "full">("split"); const [fullViewScrollOffset, setFullViewScrollOffset] = useState(0); + const [status, setStatus] = useState(null); + const statusTimerRef = useRef | null>(null); // Get memory filesystem root const memoryRoot = getMemoryFilesystemRoot(agentId); 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 const treeNodes = useMemo( @@ -182,6 +109,20 @@ export function MemfsTreeViewer({ 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 if (key.escape) { if (viewMode === "full") { @@ -349,7 +290,17 @@ export function MemfsTreeViewer({ {" "} {charCount.toLocaleString()} chars - {" "}↑↓ scroll · Esc back + {status ? ( + + {" "} + {status} + + ) : ( + + {" "}↑↓ scroll{hasGitRepo ? " · O open in browser" : ""} · Esc + back + + )} ); @@ -507,16 +458,24 @@ export function MemfsTreeViewer({ {/* Footer */} - - {" "}↑↓ navigate · Enter view · - {!isTmux && ( - - Edit in ADE - - )} - {isTmux && Edit in ADE: {adeUrl}} - · Esc close - + {status ? ( + + {" "} + {status} + + ) : ( + + {" "}↑↓ navigate · Enter view · + {!isTmux && ( + + Edit in ADE + + )} + {isTmux && Edit in ADE: {adeUrl}} + {hasGitRepo && · O open in browser} + · Esc close + + )} ); diff --git a/src/web/generate-memory-viewer.ts b/src/web/generate-memory-viewer.ts new file mode 100644 index 0000000..22562e2 --- /dev/null +++ b/src/web/generate-memory-viewer.ts @@ -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 { + 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; + 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 = {}; + 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> { + 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(); + 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> { + 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(); + 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 { + 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 { + // 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 { + 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 injection + const jsonPayload = JSON.stringify(data).replace(/", + 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 }; +} diff --git a/src/web/html.d.ts b/src/web/html.d.ts new file mode 100644 index 0000000..56302c2 --- /dev/null +++ b/src/web/html.d.ts @@ -0,0 +1,4 @@ +declare module "*memory-viewer-template.txt" { + const content: string; + export default content; +} diff --git a/src/web/memory-viewer-template.txt b/src/web/memory-viewer-template.txt new file mode 100644 index 0000000..8747e67 --- /dev/null +++ b/src/web/memory-viewer-template.txt @@ -0,0 +1,1627 @@ + + + + + +Memory Palace | Letta Code + + + +
+
+

Memory Palace

+
+ +
+
+
+ +
+
+ + + +
+
+
+
+
+
+
+
+
+
+ + + + + + + +
+
+
+ + + + + + diff --git a/src/web/types.ts b/src/web/types.ts new file mode 100644 index 0000000..88a42a4 --- /dev/null +++ b/src/web/types.ts @@ -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; + 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 +}