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:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)]"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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 */
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
Reference in New Issue
Block a user