feat: add MemoryPanel component and API for managing global memory files

- Introduced MemoryPanel component for viewing and editing EvoScientist's memory files.
- Implemented API routes for memory file operations: listing, reading, writing, and deleting files.
- Enhanced button styles for better UI consistency.
- Integrated memory management into the main application page.
This commit is contained in:
Xi Zhang
2026-06-03 11:47:45 +01:00
parent 58e2634108
commit 05915cc9c5
16 changed files with 1287 additions and 131 deletions
+89
View File
@@ -0,0 +1,89 @@
// API for EvoScientist's global memory files (~/.evoscientist/memories).
// GET → list every memory file
// GET ?path=<rel> → read one text file's content
// PUT {path, content}→ create or overwrite a text file
// DELETE ?path=<rel> → delete a file
//
// All mutations are same-origin only (loopback dev UI, no auth) and pass through
// the traversal/symlink guards in lib/server/memory.ts.
import { NextRequest, NextResponse } from "next/server";
import {
listMemory,
readMemory,
writeMemory,
deleteMemory,
isCrossOrigin,
} from "@/lib/server/memory";
export const runtime = "nodejs";
// Reject oversized request bodies before buffering/parsing them. The precise
// per-content cap lives in writeMemory; this is a coarse upfront guard (a 1MB
// content string JSON-encodes larger, so allow generous headroom).
const MAX_BODY_BYTES = 4 * 1024 * 1024;
function fail(error: unknown, status = 400) {
return NextResponse.json(
{
error: error instanceof Error ? error.message : "Memory request failed.",
},
{ status }
);
}
export async function GET(request: NextRequest) {
try {
if (isCrossOrigin(request)) {
return fail("Cross-origin memory access is not allowed.", 403);
}
const path = request.nextUrl.searchParams.get("path");
if (path) {
const file = await readMemory(path);
return NextResponse.json(file);
}
return NextResponse.json(await listMemory());
} catch (error) {
return fail(error);
}
}
export async function PUT(request: NextRequest) {
try {
if (isCrossOrigin(request)) {
return fail("Cross-origin memory access is not allowed.", 403);
}
const declaredLen = Number(request.headers.get("content-length") || 0);
if (declaredLen > MAX_BODY_BYTES) {
return fail("Request body is too large.", 413);
}
const body = (await request.json().catch(() => null)) as {
path?: unknown;
content?: unknown;
} | null;
if (!body || typeof body.path !== "string") {
return fail("A file path is required.");
}
if (typeof body.content !== "string") {
return fail("File content is required.");
}
const file = await writeMemory(body.path, body.content);
return NextResponse.json(file);
} catch (error) {
return fail(error);
}
}
export async function DELETE(request: NextRequest) {
try {
if (isCrossOrigin(request)) {
return fail("Cross-origin memory access is not allowed.", 403);
}
const path = request.nextUrl.searchParams.get("path");
if (!path) return fail("A file path is required.");
await deleteMemory(path);
return NextResponse.json({ ok: true });
} catch (error) {
return fail(error);
}
}
+2 -2
View File
@@ -92,7 +92,7 @@ export function AskUserInterrupt({
className={cn(
"rounded-md border px-3 py-2 text-left text-sm transition-colors",
selected
? "border-[var(--brand)] bg-[var(--brand)] text-[var(--brand-foreground)]"
? "border-[var(--brand-solid)] bg-[var(--brand-solid)] text-[var(--brand-foreground)]"
: "border-border bg-background hover:bg-accent"
)}
>
@@ -158,7 +158,7 @@ export function AskUserInterrupt({
size="sm"
onClick={() => onSubmit(answers)}
disabled={!canSubmit || isLoading}
className="bg-[var(--brand)] text-[var(--brand-foreground)] hover:bg-[var(--brand-hover)]"
className="bg-[var(--brand-solid)] text-[var(--brand-foreground)] hover:opacity-90"
>
{isLoading ? "Submitting…" : "Submit"}
</Button>
+1 -1
View File
@@ -9,7 +9,7 @@
*/
export function BetaBadge() {
return (
<span className="hidden rounded-lg border border-teal-300/50 bg-teal-100/60 px-1.5 py-0.5 text-[10px] font-semibold tracking-wide text-teal-800 shadow-sm backdrop-blur-sm dark:border-teal-700/40 dark:bg-teal-900/30 dark:text-teal-300 min-[420px]:inline-flex">
<span className="border-[var(--brand)]/30 bg-[var(--brand)]/10 hidden rounded-lg border px-1.5 py-0.5 text-[10px] font-semibold tracking-wide text-[var(--brand)] shadow-sm backdrop-blur-sm min-[420px]:inline-flex">
BETA
</span>
);
+8 -8
View File
@@ -77,21 +77,21 @@ const getStatusIcon = (status: TodoItem["status"], className?: string) => {
return (
<CheckCircle
size={16}
className={cn("text-success/80", className)}
className={cn("text-[var(--color-success)]", className)}
/>
);
case "in_progress":
return (
<Clock
size={16}
className={cn("text-warning/80", className)}
className={cn("text-[var(--color-warning)]", className)}
/>
);
default:
return (
<Circle
size={16}
className={cn("text-tertiary/70", className)}
className={cn("text-[var(--color-text-tertiary)]", className)}
/>
);
}
@@ -595,7 +595,7 @@ export const ChatInterface = React.memo<ChatInterfaceProps>(({ assistant }) => {
key={prompt}
type="button"
onClick={() => handleSuggestedPrompt(prompt)}
className="hover:border-primary/40 max-w-full rounded-full border border-border bg-card px-3 py-2 text-sm text-foreground shadow-sm transition-colors hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring"
className="max-w-full rounded-full border border-border bg-card px-3 py-2 text-sm text-foreground shadow-sm transition-colors hover:border-[var(--color-border)] hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring"
>
{prompt}
</button>
@@ -688,7 +688,7 @@ export const ChatInterface = React.memo<ChatInterfaceProps>(({ assistant }) => {
<CheckCircle
key="icon"
size={16}
className="text-success/80"
className="text-[var(--color-success)]"
/>,
<span
key="label"
@@ -725,7 +725,7 @@ export const ChatInterface = React.memo<ChatInterfaceProps>(({ assistant }) => {
<Circle
key="icon"
size={16}
className="text-tertiary/70"
className="text-[var(--color-text-tertiary)]"
/>,
<span
key="label"
@@ -755,7 +755,7 @@ export const ChatInterface = React.memo<ChatInterfaceProps>(({ assistant }) => {
>
<FileIcon size={16} />
Files (State)
<span className="h-4 min-w-4 rounded-full bg-[var(--brand)] px-0.5 text-center text-[10px] leading-[16px] text-white">
<span className="h-4 min-w-4 rounded-full bg-[var(--brand-solid)] px-0.5 text-center text-[10px] leading-[16px] text-[var(--brand-foreground)]">
{Object.keys(files).length}
</span>
</button>
@@ -822,7 +822,7 @@ export const ChatInterface = React.memo<ChatInterfaceProps>(({ assistant }) => {
aria-expanded={metaOpen === "files"}
>
Files (State)
<span className="h-4 min-w-4 rounded-full bg-[var(--brand)] px-0.5 text-center text-[10px] leading-[16px] text-white">
<span className="h-4 min-w-4 rounded-full bg-[var(--brand-solid)] px-0.5 text-center text-[10px] leading-[16px] text-[var(--brand-foreground)]">
{Object.keys(files).length}
</span>
</button>
+3 -3
View File
@@ -341,7 +341,7 @@ export const ChatMessage = React.memo<ChatMessageProps>(
{isSubAgentExpanded(subAgent.id) && (
<div className="w-full max-w-full">
<div className="bg-surface border-border-light rounded-md border p-4">
<h4 className="text-primary/70 mb-2 text-xs font-semibold uppercase tracking-wider">
<h4 className="mb-2 text-xs font-semibold uppercase tracking-wider text-[var(--color-text-secondary)]">
Input
</h4>
<div className="mb-4">
@@ -351,7 +351,7 @@ export const ChatMessage = React.memo<ChatMessageProps>(
</div>
{(subAgentSteps?.[subAgent.id]?.length ?? 0) > 0 && (
<>
<h4 className="text-primary/70 mb-2 text-xs font-semibold uppercase tracking-wider">
<h4 className="mb-2 text-xs font-semibold uppercase tracking-wider text-[var(--color-text-secondary)]">
Steps
</h4>
<div className="mb-4">
@@ -364,7 +364,7 @@ export const ChatMessage = React.memo<ChatMessageProps>(
)}
{subAgent.output && (
<>
<h4 className="text-primary/70 mb-2 text-xs font-semibold uppercase tracking-wider">
<h4 className="mb-2 text-xs font-semibold uppercase tracking-wider text-[var(--color-text-secondary)]">
Output
</h4>
<MarkdownContent
+1 -1
View File
@@ -144,7 +144,7 @@ export const FileViewDialog = React.memo<{
</DialogTitle>
<div className="mb-4 flex items-center justify-between border-b border-border pb-4">
<div className="flex min-w-0 items-center gap-2">
<FileText className="text-primary/50 h-5 w-5 shrink-0" />
<FileText className="h-5 w-5 shrink-0 text-[var(--color-text-tertiary)]" />
{isEditingMode && file === null ? (
<Input
value={fileName}
+1 -1
View File
@@ -95,7 +95,7 @@ export const MarkdownContent = React.memo<MarkdownContentProps>(
},
blockquote({ children }: { children?: React.ReactNode }) {
return (
<blockquote className="text-primary/50 my-4 border-l-4 border-border pl-4 italic">
<blockquote className="my-4 border-l-4 border-border pl-4 italic text-[var(--color-text-tertiary)]">
{children}
</blockquote>
);
+700
View File
@@ -0,0 +1,700 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
ArrowLeft,
BrainCircuit,
Check,
Copy,
Eye,
FileText,
Loader2,
Pencil,
Plus,
RotateCw,
Save,
Trash2,
} from "lucide-react";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { oneDark } from "react-syntax-highlighter/dist/esm/styles/prism";
import { MarkdownContent } from "@/app/components/MarkdownContent";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
interface MemoryEntry {
path: string;
size: number;
mtime: number;
editable: boolean;
}
interface MemoryListing {
dir: string;
exists: boolean;
entries: MemoryEntry[];
truncated: boolean;
}
interface MemoryFile {
path: string;
content: string;
size: number;
mtime: number;
}
const LANGUAGE_MAP: Record<string, string> = {
json: "json",
yaml: "yaml",
yml: "yaml",
csv: "text",
tsv: "text",
log: "text",
txt: "text",
tex: "latex",
bib: "latex",
rst: "markdown",
};
// Friendly, ordered groups derived from a memory file's relative path.
const GROUP_ORDER = [
"Core memory",
"Profile",
"Projects",
"Evolution reports",
] as const;
function groupOf(path: string): string {
const parts = path.split("/");
if (parts[0] === "profile") {
return parts[1] === "projects" ? "Projects" : "Profile";
}
if (parts[0] === "evolution-reports") return "Evolution reports";
if (parts.length === 1) return "Core memory";
return parts[0];
}
/** A short subtitle shown under the file name (the containing dir, if any). */
function subPathOf(path: string): string {
const i = path.lastIndexOf("/");
return i >= 0 ? path.slice(0, i) : "";
}
function fileNameOf(path: string): string {
return path.split("/").pop() || path;
}
function extOf(name: string): string {
const i = name.lastIndexOf(".");
return i >= 0 ? name.slice(i + 1).toLowerCase() : "";
}
function formatBytes(n: number): string {
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
}
function formatTime(ms: number): string {
if (!ms) return "";
try {
return new Intl.DateTimeFormat(undefined, {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
hourCycle: "h23",
}).format(new Date(ms));
} catch {
return "";
}
}
export function MemoryPanel() {
const [listing, setListing] = useState<MemoryListing | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selected, setSelected] = useState<string | null>(null);
const [file, setFile] = useState<MemoryFile | null>(null);
const [fileLoading, setFileLoading] = useState(false);
const [fileError, setFileError] = useState<string | null>(null);
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState("");
const [saving, setSaving] = useState(false);
const [copied, setCopied] = useState(false);
const [newOpen, setNewOpen] = useState(false);
const [newName, setNewName] = useState("");
const [newBusy, setNewBusy] = useState(false);
const [newError, setNewError] = useState<string | null>(null);
const didAutoSelect = useRef(false);
// Monotonic id so a slow file fetch can't overwrite a newer selection.
const reqRef = useRef(0);
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
const res = await fetch("/api/memory");
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Failed to load memory.");
setListing(data as MemoryListing);
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to load memory.");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
load();
}, [load]);
// Group + sort the file list for the sidebar.
const groups = useMemo(() => {
const entries = listing?.entries ?? [];
const byGroup = new Map<string, MemoryEntry[]>();
for (const e of entries) {
const g = groupOf(e.path);
(byGroup.get(g) ?? byGroup.set(g, []).get(g)!).push(e);
}
const names = [...byGroup.keys()].sort((a, b) => {
const ia = GROUP_ORDER.indexOf(a as (typeof GROUP_ORDER)[number]);
const ib = GROUP_ORDER.indexOf(b as (typeof GROUP_ORDER)[number]);
if (ia !== -1 || ib !== -1) {
return (ia === -1 ? 99 : ia) - (ib === -1 ? 99 : ib);
}
return a.localeCompare(b);
});
return names.map((name) => ({
name,
files: byGroup.get(name)!.sort((a, b) => a.path.localeCompare(b.path)),
}));
}, [listing]);
const openFile = useCallback(async (path: string) => {
const reqId = ++reqRef.current;
setSelected(path);
setEditing(false);
setFile(null);
setFileError(null);
setFileLoading(true);
try {
const res = await fetch(`/api/memory?path=${encodeURIComponent(path)}`);
const data = await res.json();
// A newer openFile started while this was in flight — drop the stale result.
if (reqId !== reqRef.current) return;
if (!res.ok) throw new Error(data.error || "Failed to open file.");
setFile(data as MemoryFile);
} catch (e) {
if (reqId !== reqRef.current) return;
setFileError(e instanceof Error ? e.message : "Failed to open file.");
} finally {
if (reqId === reqRef.current) setFileLoading(false);
}
}, []);
// Auto-open the first file once, so the panel isn't empty on first view.
useEffect(() => {
if (didAutoSelect.current || selected) return;
const first = groups[0]?.files[0];
if (first) {
didAutoSelect.current = true;
openFile(first.path);
}
}, [groups, selected, openFile]);
const startEdit = () => {
if (!file) return;
setDraft(file.content);
setEditing(true);
};
const save = async () => {
if (!selected) return;
const reqId = reqRef.current; // detect a file switch during the await
setSaving(true);
setFileError(null);
try {
const res = await fetch("/api/memory", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path: selected, content: draft }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Failed to save.");
// If the user switched files mid-save, don't clobber the now-current
// file's view (the save itself still persisted to disk).
if (reqId === reqRef.current) {
setFile(data as MemoryFile);
setEditing(false);
}
// Reflect the new size/mtime in the list.
load();
} catch (e) {
if (reqId === reqRef.current) {
setFileError(e instanceof Error ? e.message : "Failed to save.");
}
} finally {
setSaving(false);
}
};
const remove = async () => {
if (!selected) return;
if (
!window.confirm(`Delete "${fileNameOf(selected)}"? This can't be undone.`)
)
return;
const reqId = reqRef.current; // detect a file switch during the await
try {
const res = await fetch(
`/api/memory?path=${encodeURIComponent(selected)}`,
{ method: "DELETE" }
);
if (!res.ok) {
const data = await res.json().catch(() => null);
throw new Error(data?.error || "Failed to delete.");
}
// Only reset the view if the user is still on the file they deleted.
if (reqId === reqRef.current) {
setSelected(null);
setFile(null);
setEditing(false);
}
load();
} catch (e) {
if (reqId === reqRef.current) {
setFileError(e instanceof Error ? e.message : "Failed to delete.");
}
}
};
const copy = async () => {
if (!file) return;
try {
await navigator.clipboard.writeText(file.content);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
} catch {
// clipboard may be unavailable; ignore
}
};
const createNew = async () => {
let name = newName.trim();
if (!name) {
setNewError("Enter a file name.");
return;
}
if (!/\.[A-Za-z0-9]+$/.test(name)) name += ".md";
setNewBusy(true);
setNewError(null);
try {
const res = await fetch("/api/memory", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
path: name,
content: `# ${fileNameOf(name).replace(/\.[^.]+$/, "")}\n\n`,
}),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || "Failed to create file.");
setNewOpen(false);
setNewName("");
await load();
await openFile((data as MemoryFile).path);
setDraft((data as MemoryFile).content);
setEditing(true);
} catch (e) {
setNewError(e instanceof Error ? e.message : "Failed to create file.");
} finally {
setNewBusy(false);
}
};
// Unsaved-edit guard: confirm before navigating away from dirty edits.
const dirty = editing && file != null && draft !== file.content;
const confirmDiscard = () =>
!dirty || window.confirm("Discard your unsaved changes?");
const ext = selected ? extOf(fileNameOf(selected)) : "";
const isMarkdown = ext === "md" || ext === "markdown";
return (
<div className="flex h-full flex-col overflow-hidden">
<header className="flex flex-shrink-0 items-start justify-between gap-4 border-b border-border px-4 py-4 sm:px-6">
<div className="min-w-0">
<div className="flex items-center gap-2">
<BrainCircuit
className="size-5 text-[var(--brand)]"
aria-hidden="true"
/>
<h2 className="text-xl font-semibold sm:text-2xl">Memory</h2>
</div>
<p className="mt-1 text-sm text-muted-foreground">
EvoScientist&apos;s long-term memory — what it knows about you, your
research taste, and lessons from past work. View or edit it
directly.
</p>
</div>
<div className="flex flex-shrink-0 items-center gap-1.5">
<button
type="button"
onClick={() => {
setNewName("");
setNewError(null);
setNewOpen(true);
}}
className="inline-flex items-center gap-1.5 rounded-md bg-[var(--brand-solid)] px-2.5 py-1.5 text-xs font-medium text-[var(--brand-foreground)] transition-opacity hover:opacity-90"
>
<Plus
className="size-3.5"
aria-hidden="true"
/>
<span className="hidden sm:inline">New</span>
</button>
<button
type="button"
onClick={load}
disabled={loading}
aria-label="Refresh"
className="rounded-md p-2 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
>
<RotateCw
className={loading ? "size-4 animate-spin" : "size-4"}
aria-hidden="true"
/>
</button>
</div>
</header>
{error ? (
<div className="flex flex-1 items-center justify-center p-8">
<p className="text-sm text-destructive">{error}</p>
</div>
) : loading && !listing ? (
<div className="flex flex-1 items-center justify-center gap-2 text-sm text-muted-foreground">
<Loader2
className="size-4 animate-spin"
aria-hidden="true"
/>
Loading memory…
</div>
) : listing && !listing.exists ? (
<EmptyAll dir={listing.dir} />
) : listing && listing.entries.length === 0 ? (
<EmptyAll dir={listing.dir} />
) : (
<div className="flex min-h-0 flex-1">
{/* File list */}
<aside
className={cn(
"w-full flex-col border-r border-border md:flex md:w-72 md:flex-shrink-0",
selected ? "hidden md:flex" : "flex"
)}
>
<ScrollArea className="h-0 flex-1">
<div className="p-2">
{groups.map((group) => (
<div
key={group.name}
className="mb-3"
>
<h4 className="px-2 py-1.5 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{group.name}
</h4>
<div className="flex flex-col">
{group.files.map((entry) => {
const sub = subPathOf(entry.path);
const active = entry.path === selected;
return (
<button
key={entry.path}
type="button"
onClick={() => {
if (confirmDiscard()) openFile(entry.path);
}}
className={cn(
"flex items-start gap-2 rounded-md px-2 py-2 text-left transition-colors",
active ? "bg-accent" : "hover:bg-accent/60"
)}
aria-current={active}
>
<FileText
className="mt-0.5 size-4 shrink-0 text-muted-foreground"
aria-hidden="true"
/>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium">
{fileNameOf(entry.path)}
</span>
<span className="mt-0.5 flex items-center gap-1.5 text-xs text-muted-foreground">
{sub && <span className="truncate">{sub}</span>}
<span className="shrink-0 tabular-nums">
{formatBytes(entry.size)}
</span>
</span>
</span>
</button>
);
})}
</div>
</div>
))}
{listing?.truncated && (
<p className="px-2 py-2 text-xs text-muted-foreground">
Showing the first {listing.entries.length} files.
</p>
)}
</div>
</ScrollArea>
</aside>
{/* Viewer / editor */}
<section
className={cn(
"min-w-0 flex-1 flex-col",
selected ? "flex" : "hidden md:flex"
)}
>
{!selected ? (
<div className="flex flex-1 items-center justify-center p-8">
<p className="text-sm text-muted-foreground">
Select a memory file to view or edit.
</p>
</div>
) : (
<>
<div className="flex flex-shrink-0 flex-wrap items-center gap-2 border-b border-border px-3 py-2.5 sm:px-4">
<button
type="button"
onClick={() => {
if (!confirmDiscard()) return;
setSelected(null);
setFile(null);
setEditing(false);
}}
aria-label="Back to list"
className="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground md:hidden"
>
<ArrowLeft
className="size-4"
aria-hidden="true"
/>
</button>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">
{fileNameOf(selected)}
</p>
{file && (
<p className="truncate text-xs text-muted-foreground">
{subPathOf(selected) && `${subPathOf(selected)} · `}
{formatBytes(file.size)}
{file.mtime ? ` · ${formatTime(file.mtime)}` : ""}
</p>
)}
</div>
<div className="flex shrink-0 items-center gap-1">
{!editing ? (
<>
<Button
variant="ghost"
size="sm"
className="h-8 px-2"
onClick={copy}
disabled={!file}
title="Copy"
>
{copied ? (
<Check className="size-4" />
) : (
<Copy className="size-4" />
)}
</Button>
<Button
variant="ghost"
size="sm"
className="h-8 px-2"
onClick={startEdit}
disabled={!file}
>
<Pencil className="mr-1 size-4" />
Edit
</Button>
<Button
variant="ghost"
size="sm"
className="h-8 px-2 text-muted-foreground hover:text-destructive"
onClick={remove}
title="Delete"
>
<Trash2 className="size-4" />
</Button>
</>
) : (
<>
<Button
variant="ghost"
size="sm"
className="h-8 px-2"
onClick={() => {
if (confirmDiscard()) setEditing(false);
}}
disabled={saving}
>
<Eye className="mr-1 size-4" />
Cancel
</Button>
<Button
size="sm"
className="h-8 px-3"
onClick={save}
disabled={saving}
>
{saving ? (
<Loader2 className="mr-1 size-4 animate-spin" />
) : (
<Save className="mr-1 size-4" />
)}
{saving ? "Saving…" : "Save"}
</Button>
</>
)}
</div>
</div>
{fileError && (
<p
role="alert"
className="px-4 py-2 text-sm text-destructive"
>
{fileError}
</p>
)}
<div className="min-h-0 flex-1 overflow-hidden">
{fileLoading ? (
<div className="flex h-full items-center justify-center">
<Loader2 className="size-5 animate-spin text-muted-foreground" />
</div>
) : editing ? (
<textarea
value={draft}
onChange={(e) => setDraft(e.target.value)}
spellCheck={false}
className="bg-surface h-full w-full resize-none p-4 font-mono text-sm leading-relaxed text-foreground outline-none"
placeholder="Write memory…"
/>
) : file ? (
<ScrollArea className="h-full">
<div className="mx-auto max-w-[820px] px-4 py-6 sm:px-8">
{file.content.trim().length === 0 ? (
<p className="text-sm text-muted-foreground">
This file is empty.
</p>
) : isMarkdown ? (
<MarkdownContent content={file.content} />
) : (
<SyntaxHighlighter
language={LANGUAGE_MAP[ext] || "text"}
style={oneDark}
customStyle={{
margin: 0,
borderRadius: "0.5rem",
fontSize: "0.85rem",
}}
wrapLongLines
>
{file.content}
</SyntaxHighlighter>
)}
</div>
</ScrollArea>
) : null}
</div>
</>
)}
</section>
</div>
)}
{/* New memory dialog */}
<Dialog
open={newOpen}
onOpenChange={(open) => {
if (!newBusy) setNewOpen(open);
}}
>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>New memory file</DialogTitle>
<DialogDescription>
Create a markdown file in the memory directory. Use a path like{" "}
<code className="font-mono text-xs">notes/idea.md</code> to nest
it.
</DialogDescription>
</DialogHeader>
<Input
autoFocus
value={newName}
onChange={(e) => setNewName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
createNew();
}
}}
placeholder="e.g. research-notes.md"
disabled={newBusy}
/>
{newError && <p className="text-sm text-destructive">{newError}</p>}
<DialogFooter>
<Button
variant="outline"
onClick={() => setNewOpen(false)}
disabled={newBusy}
>
Cancel
</Button>
<Button
onClick={createNew}
disabled={newBusy || !newName.trim()}
>
{newBusy ? "Creating…" : "Create"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
function EmptyAll({ dir }: { dir: string }) {
return (
<div className="flex flex-1 flex-col items-center justify-center gap-2 p-8 text-center">
<BrainCircuit
className="size-10 text-muted-foreground/40"
aria-hidden="true"
/>
<p className="text-sm font-medium">No memory yet</p>
<p className="max-w-md text-sm text-muted-foreground">
EvoScientist will write what it learns here as you work together. Memory
lives at <code className="break-all font-mono text-xs">{dir}</code>.
</p>
</div>
);
}
+2 -2
View File
@@ -366,7 +366,7 @@ function SkillTile({
type="button"
onClick={onUpdate}
disabled={!!busy}
className="inline-flex items-center gap-1.5 rounded-md bg-[var(--brand)] px-2.5 py-1 text-xs font-medium text-white transition-opacity hover:opacity-90 disabled:opacity-50"
className="inline-flex items-center gap-1.5 rounded-md bg-[var(--brand-solid)] px-2.5 py-1 text-xs font-medium text-[var(--brand-foreground)] transition-opacity hover:opacity-90 disabled:opacity-50"
title={latestVersion ? `Update to v${latestVersion}` : "Update"}
>
{busy === "update" ? (
@@ -412,7 +412,7 @@ function SkillTile({
type="button"
onClick={onInstall}
disabled={!!busy}
className="inline-flex items-center gap-1.5 rounded-md bg-[var(--brand)] px-2.5 py-1 text-xs font-medium text-white transition-opacity hover:opacity-90 disabled:opacity-50"
className="inline-flex items-center gap-1.5 rounded-md bg-[var(--brand-solid)] px-2.5 py-1 text-xs font-medium text-[var(--brand-foreground)] transition-opacity hover:opacity-90 disabled:opacity-50"
>
{busy === "install" ? (
<Loader2
+3 -3
View File
@@ -147,21 +147,21 @@ export const TasksFilesSidebar = React.memo<{
return (
<CheckCircle
size={12}
className="text-success/80"
className="text-[var(--color-success)]"
/>
);
case "in_progress":
return (
<Clock
size={12}
className="text-warning/80"
className="text-[var(--color-warning)]"
/>
);
default:
return (
<Circle
size={10}
className="text-tertiary/70"
className="text-[var(--color-text-tertiary)]"
/>
);
}
+19 -1
View File
@@ -2,6 +2,7 @@
import { useEffect, useMemo, useState, useRef, useCallback } from "react";
import {
BrainCircuit,
Loader2,
MessageSquare,
Pencil,
@@ -357,7 +358,24 @@ export function ThreadList({
/>
Research Skills
</button>
{view !== "skills" && (
<button
type="button"
onClick={() => {
setView("memory");
onClose?.();
}}
className={cn(
"flex flex-shrink-0 items-center gap-3 border-b border-border p-4 text-left text-sm font-medium transition-colors hover:bg-accent",
view === "memory" && "bg-accent"
)}
>
<BrainCircuit
className="size-4"
aria-hidden="true"
/>
Memory
</button>
{view !== "skills" && view !== "memory" && (
<div className="flex-shrink-0 border-b border-border p-3">
<div className="relative">
<Search
+1 -1
View File
@@ -143,7 +143,7 @@ export const WorkspaceFileDialog = React.memo<{
<DialogTitle className="sr-only">{path}</DialogTitle>
<div className="mb-4 flex items-center justify-between border-b border-border pb-4">
<div className="flex min-w-0 items-center gap-2">
<FileText className="text-primary/50 h-5 w-5 shrink-0" />
<FileText className="h-5 w-5 shrink-0 text-[var(--color-text-tertiary)]" />
<span className="overflow-hidden text-ellipsis whitespace-nowrap text-base font-medium text-primary">
{path}
</span>
+121 -105
View File
@@ -10,47 +10,56 @@
:root {
/* App-specific color variables */
--color-primary: #1c3c3c;
--color-user-message: #076699;
--color-user-message-bg: #e8f4f8;
--color-avatar-bg: #e8ebeb;
--color-secondary: #1c3c3c;
--color-success: #10b981;
--color-warning: #f59e0b;
--color-error: #ef4444;
--color-background: #f9f9f9;
--color-subagent-hover: #bbc4c4;
--color-surface: #f9fafb;
--color-border: #e5e7eb;
--color-border-light: #f3f4f6;
--color-text-primary: #111827;
--color-text-secondary: #6b7280;
--color-text-tertiary: #9ca3af;
/* App-specific colors — warm "paper" palette in the Claude Code desktop
style. Light mode: ivory background, true-white cards, warm charcoal text. */
--color-primary: #33302a;
--color-user-message: #0e5c6e;
--color-user-message-bg: #e6f2f5;
--color-avatar-bg: #efe4db;
--color-secondary: #6b6557;
--color-success: #3f7a52;
--color-warning: #946618;
--color-error: #c0392b;
--color-background: #faf8f3;
--color-subagent-hover: #efe7da;
--color-surface: #ffffff;
--color-border: #e6dfd2;
--color-border-light: #f0ebe1;
--color-text-primary: #33302a;
--color-text-secondary: #6b6557;
--color-text-tertiary: #736e60;
--color-file-button: #ffffff;
--color-file-button-hover: #e5e7eb;
--color-file-button-hover: #f0ebe1;
/* Brand accent — single source of truth. Change this to re-theme
(e.g. EvoScientist blue) in one place. */
--brand: #2f6868;
--brand-hover: #275757;
/* Brand accent — single source of truth = EvoScientist cyan (#00BCD4,
deepened to #00788f so white text passes WCAG AA on the warm paper bg).
The warm "paper" base stays; cyan carries the EvoScientist identity. */
--brand: #066679;
--brand-hover: #054e5b;
/* Solid filled-button background. Same as --brand in light; in dark it's a
deeper, calmer cyan so buttons don't glow (see .dark). White text on both. */
--brand-solid: #066679;
--brand-foreground: #ffffff;
/* Dark theme colors */
--color-primary-dark: #2dd4bf;
--color-user-message-dark: #076699;
--color-avatar-bg-dark: #bcb2fd;
--color-secondary-dark: #2dd4bf;
--color-success-dark: #34d399;
--color-warning-dark: #fbbf24;
--color-error-dark: #f87171;
--color-background-dark: #0f0f0f;
--color-subagent-hover-dark: #d0c9fe;
--color-surface-dark: #1a1a1a;
--color-border-dark: #2d2d2d;
--color-border-light-dark: #232323;
--color-text-primary-dark: #f3f4f6;
--color-text-secondary-dark: #9ca3af;
--color-text-tertiary-dark: #6b7280;
/* deep-agents-ui base tokens. Its tailwind.config.mjs maps semantic classes
(text-primary, text-tertiary, text-success, text-warning, bg-secondary,
border-primary, the global scrollbar) to these LangSmith-style vars, but
the base never defined them, so those classes rendered colorless. Alias
them to our palette. Declared once: the var() refs resolve per-mode at the
use site because the referenced color tokens are overridden in .dark. */
--text-primary: var(--color-text-primary);
--text-secondary: var(--color-text-secondary);
--text-tertiary: var(--color-text-tertiary);
--text-quaternary: var(--color-text-tertiary);
--text-success: var(--color-success);
--text-warning: var(--color-warning);
--text-error: var(--color-error);
--border-primary: var(--color-border);
--border-secondary: var(--color-border-light);
--bg-primary: var(--color-background);
--bg-secondary: hsl(var(--secondary));
--scrollbar-thumb: var(--color-border);
--scrollbar-thumb-hover: var(--color-text-tertiary);
/* Spacing variables */
--spacing-xs: 0.25rem;
@@ -108,31 +117,31 @@
/* Tailwind/Radix UI component variables */
--radius: 0.5rem;
--background: 0 0% 98%;
--foreground: 220 13% 13%;
--background: 43 41% 97%;
--foreground: 40 10% 18%;
--card: 0 0% 100%;
--card-foreground: 220 13% 13%;
--card-foreground: 40 10% 18%;
--popover: 0 0% 100%;
--popover-foreground: 220 13% 13%;
--primary: 180 35% 17%;
--popover-foreground: 40 10% 18%;
--primary: 190 90% 25%;
--primary-foreground: 0 0% 100%;
--secondary: 220 13% 91%;
--secondary-foreground: 220 13% 13%;
--muted: 220 13% 95%;
--muted-foreground: 220 9% 46%;
--accent: 220 13% 95%;
--accent-foreground: 220 13% 13%;
--destructive: 0 84% 60%;
--secondary: 40 33% 91%;
--secondary-foreground: 40 10% 18%;
--muted: 40 37% 92%;
--muted-foreground: 42 10% 38%;
--accent: 192 33% 91%;
--accent-foreground: 40 8% 22%;
--destructive: 6 63% 46%;
--destructive-foreground: 0 0% 100%;
--border: 220 13% 91%;
--input: 220 13% 91%;
--ring: 180 35% 17%;
--sidebar: 220 13% 95%;
--chart-1: 12 76% 61%;
--chart-2: 173 58% 39%;
--chart-3: 197 37% 24%;
--chart-4: 43 74% 66%;
--chart-5: 27 87% 67%;
--border: 39 29% 86%;
--input: 39 25% 80%;
--ring: 190 90% 25%;
--sidebar: 39 39% 93%;
--chart-1: 190 85% 28%;
--chart-2: 173 40% 40%;
--chart-3: 37 65% 54%;
--chart-4: 212 27% 49%;
--chart-5: 330 26% 48%;
}
/* Dark theme — applied via the `.dark` class on <html> (set by ThemeProvider
@@ -140,53 +149,60 @@
`dark:` utilities off the same class, so CSS variables and utilities switch
together. The default theme follows the system preference. */
.dark {
/* App-specific color variables */
--color-primary: #1c3c3c;
--color-user-message: #065a8a;
--color-user-message-bg: #2d2d2d;
--color-avatar-bg: #1c3c3c;
--color-secondary: #bbc4c4;
--color-success: #34d399;
--color-warning: #fbbf24;
--color-error: #f87171;
--color-background: #202020;
--color-subagent-hover: #1e3f3f;
--color-surface: #2a2a2a;
--color-border: #404040;
--color-border-light: #353535;
--color-text-primary: #f3f4f6;
--color-text-secondary: #9ca3af;
--color-text-tertiary: #6b7280;
--color-file-button: #2a2a2a;
--color-file-button-hover: #353535;
/* App-specific colors — warm charcoal dark mode (the same paper identity,
lifted): near-black warm background, book-cloth terracotta accent. */
--color-primary: #ece7dc;
--color-user-message: #a9dce6;
--color-user-message-bg: #16343b;
--color-avatar-bg: #3a2e27;
--color-secondary: #ada694;
--color-success: #5dbe85;
--color-warning: #e0a94a;
--color-error: #e0796b;
--color-background: #23211d;
--color-subagent-hover: #3a2e27;
--color-surface: #2c2a25;
--color-border: #3a372f;
--color-border-light: #332f2a;
--color-text-primary: #ece7dc;
--color-text-secondary: #ada694;
--color-text-tertiary: #908d83;
--color-file-button: #2c2a25;
--color-file-button-hover: #332f2a;
--brand: #3b9cb0;
--brand-hover: #46a9bd;
/* Deep, saturated cyan fill: colorful (S~90%) but low-lightness so it
doesn't glow on the dark card; white text passes AA (4.82:1). */
--brand-solid: #087d91;
--brand-foreground: #ffffff;
/* Tailwind/Radix UI component variables for dark mode */
--radius: 0.5rem;
--background: 0 0% 13%;
--foreground: 220 13% 95%;
--card: 0 0% 18%;
--card-foreground: 220 13% 95%;
--popover: 0 0% 18%;
--popover-foreground: 220 13% 95%;
--primary: 174 72% 56%;
--primary-foreground: 0 0% 13%;
--secondary: 0 0% 25%;
--secondary-foreground: 220 13% 95%;
--muted: 0 0% 22%;
--muted-foreground: 220 9% 70%;
--accent: 0 0% 22%;
--accent-foreground: 220 13% 95%;
--destructive: 0 63% 71%;
--destructive-foreground: 0 0% 13%;
--border: 0 0% 28%;
--input: 0 0% 28%;
--ring: 174 72% 56%;
--sidebar: 0 0% 18%;
--chart-1: 220 70% 50%;
--chart-2: 160 60% 45%;
--chart-3: 30 80% 55%;
--chart-4: 280 65% 60%;
--chart-5: 340 75% 55%;
--background: 40 9% 13%;
--foreground: 41 30% 89%;
--card: 43 9% 16%;
--card-foreground: 41 30% 89%;
--popover: 43 9% 16%;
--popover-foreground: 41 30% 89%;
--primary: 189 90% 30%;
--primary-foreground: 0 0% 100%;
--secondary: 33 10% 18%;
--secondary-foreground: 41 30% 89%;
--muted: 38 10% 16%;
--muted-foreground: 43 13% 63%;
--accent: 196 14% 20%;
--accent-foreground: 41 30% 89%;
--destructive: 7 65% 65%;
--destructive-foreground: 30 13% 12%;
--border: 44 10% 21%;
--input: 44 10% 21%;
--ring: 190 50% 46%;
--sidebar: 36 9% 11%;
--chart-1: 190 52% 50%;
--chart-2: 173 39% 54%;
--chart-3: 40 68% 62%;
--chart-4: 215 37% 63%;
--chart-5: 332 39% 64%;
}
}
@@ -264,7 +280,7 @@ p {
}
a {
color: var(--color-primary);
color: var(--brand);
text-decoration: none;
transition: opacity 200ms ease;
@@ -384,12 +400,12 @@ button {
}
.scrollbar-pretty::-webkit-scrollbar-thumb {
background-color: #d1d5db;
background-color: hsl(var(--border));
border-radius: 9999px;
}
.scrollbar-pretty::-webkit-scrollbar-thumb:hover {
background-color: #9ca3af;
background-color: hsl(var(--muted-foreground) / 0.6);
}
/* Global diff highlighting styles */
+3
View File
@@ -18,6 +18,7 @@ import { ThreadList } from "@/app/components/ThreadList";
import { ChatProvider } from "@/providers/ChatProvider";
import { ChatInterface } from "@/app/components/ChatInterface";
import { SkillsMarketplace } from "@/app/components/SkillsMarketplace";
import { MemoryPanel } from "@/app/components/MemoryPanel";
import { ThemeToggle } from "@/app/components/ThemeToggle";
import { BetaBadge } from "@/app/components/BetaBadge";
@@ -275,6 +276,8 @@ function HomePageInner({
>
{view === "skills" ? (
<SkillsMarketplace />
) : view === "memory" ? (
<MemoryPanel />
) : (
<ChatProvider
key={chatSessionRevision}
+3 -3
View File
@@ -10,16 +10,16 @@ const buttonVariants = cva(
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
"bg-[var(--brand-solid)] text-[var(--brand-foreground)] shadow-xs hover:opacity-90",
destructive:
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
"bg-secondary text-secondary-foreground shadow-xs hover:opacity-90",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
link: "text-[var(--brand)] underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
+330
View File
@@ -0,0 +1,330 @@
// Server-only helpers for EvoScientist's on-disk *memory* — the agent's
// long-lived, cross-session knowledge. Unlike the workspace (per-deployment,
// resolved from a live sidecar) memory is GLOBAL: it lives under the data dir
// EvoScientist's paths.py resolves to, by default `~/.evoscientist/memories`.
//
// Layout written by EvoScientist (all plain markdown):
// profile/SOUL.md, profile/USER_PROFILE.md, profile/RESEARCH_TASTE.md
// profile/projects/<project-id>/PROJECT_PROFILE.md
// ideation-memory.md, experiment-memory.md (evo-memory skill)
// evolution-reports/cycle_N_*.md
//
// These are user-facing, editable knowledge files, so this module exposes
// read + write (not just read like the workspace browser). Every path is
// guarded against traversal and symlink escape, mirroring workspace.ts.
import { promises as fs } from "fs";
import { homedir } from "os";
import { join, dirname, relative, resolve, sep } from "path";
import { randomUUID } from "crypto";
import { hasControlChar } from "@/lib/server/workspace";
// Re-export so memory API routes can share the workspace cross-origin guard.
export { isCrossOrigin } from "@/lib/server/workspace";
/** EvoScientist's global data dir — `~/.evoscientist` by default (paths.py
* DATA_DIR), relocatable via EVOSCIENTIST_DATA_DIR. Mirrors skills.ts so both
* resolve the backend's data root the same way. */
function expandHome(p: string): string {
return p.startsWith("~") ? join(homedir(), p.slice(1)) : resolve(p);
}
function globalDataDir(): string {
const env = process.env.EVOSCIENTIST_DATA_DIR;
if (env && env.trim()) return expandHome(env);
return join(homedir(), ".evoscientist");
}
/**
* Lexical path of the memory directory, matching EvoScientist's paths.py:
* EVOSCIENTIST_MEMORIES_DIR > EVOSCIENTIST_MEMORY_DIR (legacy) > DATA_DIR/memories.
* Does not touch the filesystem (the dir may not exist yet).
*/
export function memoryDirPath(): string {
const env =
process.env.EVOSCIENTIST_MEMORIES_DIR ||
process.env.EVOSCIENTIST_MEMORY_DIR;
if (env && env.trim()) return expandHome(env);
return join(globalDataDir(), "memories");
}
/** Canonical memory dir if it exists on disk, else null (nothing written yet). */
async function canonicalDirIfExists(): Promise<string | null> {
try {
const real = await fs.realpath(memoryDirPath());
const stat = await fs.stat(real);
return stat.isDirectory() ? real : null;
} catch {
return null;
}
}
/** Canonical memory dir, creating it first. Used by writes. */
async function ensureCanonicalDir(): Promise<string> {
const lexical = memoryDirPath();
await fs.mkdir(lexical, { recursive: true });
return fs.realpath(lexical);
}
// ---------------------------------------------------------------------------
// What's hidden / what's editable
// ---------------------------------------------------------------------------
const IGNORED_NAMES = new Set(["__pycache__", "node_modules", "__MACOSX"]);
const IGNORED_SUFFIXES = [".pyc", ".pyo"];
/** Hide dotfiles and build noise; everything else is a real memory file. */
function isHiddenEntry(name: string): boolean {
if (name.startsWith(".")) return true;
if (IGNORED_NAMES.has(name)) return true;
return IGNORED_SUFFIXES.some((s) => name.endsWith(s));
}
/** Text extensions we render and allow editing. Memory is markdown in practice;
* the rest are here so an occasional note/data file is still viewable. */
const TEXT_EXTS = new Set([
"md",
"markdown",
"txt",
"text",
"json",
"yaml",
"yml",
"csv",
"tsv",
"log",
"tex",
"bib",
"rst",
]);
export function extOf(name: string): string {
const i = name.lastIndexOf(".");
return i >= 0 ? name.slice(i + 1).toLowerCase() : "";
}
export function isTextFile(name: string): boolean {
return TEXT_EXTS.has(extOf(name));
}
// Read/write size caps — memory files are small; refuse pathological sizes.
const MAX_READ_BYTES = 2 * 1024 * 1024;
export const MAX_WRITE_BYTES = 1 * 1024 * 1024;
const MAX_DEPTH = 8;
const MAX_ENTRIES = 2000;
// ---------------------------------------------------------------------------
// Path resolution (traversal + symlink-escape guards, cf. workspace.ts)
// ---------------------------------------------------------------------------
/** Lexical resolve of a caller path inside `root`: no control chars, no leading
* slash override, no hidden segments, no `..` escape. Returns absolute path. */
function resolveInside(root: string, relPath: string): string {
if (hasControlChar(relPath)) throw new Error("Invalid path.");
const cleaned = relPath.replaceAll("\\", "/").replace(/^\/+/, "");
if (!cleaned) throw new Error("A file path is required.");
if (cleaned.split("/").some((seg) => seg !== "" && isHiddenEntry(seg))) {
throw new Error("Path is not accessible.");
}
const target = resolve(root, cleaned);
if (target !== root && !target.startsWith(root + sep)) {
throw new Error("Path is outside the memory directory.");
}
return target;
}
/** Resolve an EXISTING memory file, defeating symlink escapes (for reads). */
async function safeResolveExisting(
root: string,
relPath: string
): Promise<string> {
const target = resolveInside(root, relPath);
let real: string;
try {
real = await fs.realpath(target);
} catch {
throw new Error("Path is not accessible.");
}
if (real !== root && !real.startsWith(root + sep)) {
throw new Error("Path is not accessible.");
}
const rel = relative(root, real);
if (rel && rel.split(sep).some((s) => s !== "" && isHiddenEntry(s))) {
throw new Error("Path is not accessible.");
}
return real;
}
/** Resolve a target for WRITING (the file may not exist yet): the lexical path
* must be inside root, and its nearest existing ancestor must canonicalize to
* inside root too (so a symlinked parent can't redirect the write out). */
async function safeResolveForWrite(
root: string,
relPath: string
): Promise<string> {
const target = resolveInside(root, relPath);
// Walk up to the first existing ancestor and verify it stays inside root.
let probe = dirname(target);
for (;;) {
try {
const realProbe = await fs.realpath(probe);
if (realProbe !== root && !realProbe.startsWith(root + sep)) {
throw new Error("Path is outside the memory directory.");
}
break;
} catch (e) {
if (e instanceof Error && e.message.includes("outside")) throw e;
const parent = dirname(probe);
if (parent === probe) break; // reached fs root
probe = parent;
}
}
return target;
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
export interface MemoryEntry {
/** Path relative to the memory root, POSIX-separated. */
path: string;
size: number;
/** Last-modified epoch ms. */
mtime: number;
/** True if we can render/edit it as text. */
editable: boolean;
}
export interface MemoryListing {
/** Absolute memory dir (for display); always set even if it doesn't exist. */
dir: string;
exists: boolean;
entries: MemoryEntry[];
truncated: boolean;
}
/** Recursively list every memory file (depth/count bounded). */
export async function listMemory(): Promise<MemoryListing> {
const dir = memoryDirPath();
const maybeRoot = await canonicalDirIfExists();
if (!maybeRoot) return { dir, exists: false, entries: [], truncated: false };
// Bind to a non-null local so the nested walk() closure keeps the narrowing.
const root: string = maybeRoot;
const out: MemoryEntry[] = [];
let truncated = false;
async function walk(absDir: string, depth: number): Promise<void> {
if (depth > MAX_DEPTH || out.length >= MAX_ENTRIES) {
if (out.length >= MAX_ENTRIES) truncated = true;
return;
}
let dirents;
try {
dirents = await fs.readdir(absDir, { withFileTypes: true });
} catch {
return;
}
// Stable, predictable order: dirs and files alphabetically.
dirents.sort((a, b) => a.name.localeCompare(b.name));
for (const ent of dirents) {
if (isHiddenEntry(ent.name)) continue;
if (out.length >= MAX_ENTRIES) {
truncated = true;
return;
}
const abs = join(absDir, ent.name);
if (ent.isDirectory()) {
await walk(abs, depth + 1);
} else if (ent.isFile()) {
let size = 0;
let mtime = 0;
try {
const st = await fs.stat(abs);
size = st.size;
mtime = st.mtimeMs;
} catch {
continue;
}
out.push({
path: relative(root, abs).split(sep).join("/"),
size,
mtime,
editable: isTextFile(ent.name) && size <= MAX_READ_BYTES,
});
}
}
}
await walk(root, 0);
return { dir, exists: true, entries: out, truncated };
}
export interface MemoryFile {
path: string;
content: string;
size: number;
mtime: number;
}
/** Read one memory text file. */
export async function readMemory(relPath: string): Promise<MemoryFile> {
const root = await canonicalDirIfExists();
if (!root) throw new Error("No memory directory found.");
if (!isTextFile(relPath)) throw new Error("This file type can't be edited.");
const abs = await safeResolveExisting(root, relPath);
const st = await fs.stat(abs);
if (!st.isFile()) throw new Error("Not a file.");
if (st.size > MAX_READ_BYTES)
throw new Error("This file is too large to open.");
const content = await fs.readFile(abs, "utf-8");
return {
path: relPath.replaceAll("\\", "/").replace(/^\/+/, ""),
content,
size: st.size,
mtime: st.mtimeMs,
};
}
/** Create or overwrite a memory text file (atomic temp + rename). */
export async function writeMemory(
relPath: string,
content: string
): Promise<MemoryFile> {
if (typeof content !== "string") throw new Error("Content must be a string.");
if (Buffer.byteLength(content, "utf-8") > MAX_WRITE_BYTES) {
throw new Error("This file is too large to save.");
}
if (!isTextFile(relPath)) {
throw new Error("Only text/markdown memory files can be saved.");
}
const root = await ensureCanonicalDir();
const target = await safeResolveForWrite(root, relPath);
await fs.mkdir(dirname(target), { recursive: true });
const tmp = `${target}.${randomUUID()}.tmp`;
try {
await fs.writeFile(tmp, content, { encoding: "utf-8", mode: 0o600 });
await fs.rename(tmp, target);
} catch (e) {
await fs.rm(tmp, { force: true }).catch(() => {});
throw e;
}
const st = await fs.stat(target);
return {
path: relPath.replaceAll("\\", "/").replace(/^\/+/, ""),
content,
size: st.size,
mtime: st.mtimeMs,
};
}
/** Permanently delete a memory file. */
export async function deleteMemory(relPath: string): Promise<void> {
const root = await canonicalDirIfExists();
if (!root) throw new Error("No memory directory found.");
const abs = await safeResolveExisting(root, relPath);
const st = await fs.stat(abs);
if (!st.isFile()) throw new Error("Only files can be deleted.");
await fs.rm(abs, { force: true });
}