+ );
+ }
+
+ return (
+
+
+ {sidebar}
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/app/components/MemoryPanel.tsx b/src/app/components/MemoryPanel.tsx
index 3b09a51..cdde051 100644
--- a/src/app/components/MemoryPanel.tsx
+++ b/src/app/components/MemoryPanel.tsx
@@ -1,481 +1,174 @@
"use client";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
- useCallback,
- useEffect,
- useMemo,
- useRef,
- useState,
- type PointerEvent as ReactPointerEvent,
-} from "react";
-import {
- ArrowLeft,
BrainCircuit,
- Check,
- Copy,
- Eye,
- FileText,
- Loader2,
- Pencil,
- Plus,
- RotateCw,
- Save,
- Trash2,
+ Network,
+ Fingerprint,
+ History,
+ RefreshCw,
} 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 { setMemorySeenAt } from "@/lib/memoryActivity";
+import { ObservationGraph } from "@/app/components/ObservationGraph";
+import { IdentityTab } from "@/app/components/IdentityTab";
import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle,
-} from "@/components/ui/dialog";
-import { Input } from "@/components/ui/input";
+ HistoryTab,
+ type ExecEntryClient,
+ type TimelineItem,
+} from "@/app/components/HistoryTab";
+import type { ObsGraphData } from "@/lib/observationGraph";
import { cn } from "@/lib/utils";
-import { copyText } from "@/lib/clipboard";
-import { isRecent, relativeTime, setMemorySeenAt } from "@/lib/memoryActivity";
-
-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 = {
- 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 "";
- }
-}
-
-const MEMORY_LIST_MIN_WIDTH = 200;
-const MEMORY_LIST_DEFAULT_WIDTH = 256; // matches the old fixed `md:w-64`
-const MEMORY_LIST_WIDTH_KEY = "evoscientist-memory-list-width";
-
-/** Clamp the list width to [min, container − 360] so the viewer always has room.
- * Falls back to a fixed cap when the container isn't measured yet. */
-function clampListWidth(w: number, container: HTMLElement | null): number {
- const max = container
- ? Math.max(MEMORY_LIST_MIN_WIDTH + 80, container.clientWidth - 360)
- : 640;
- return Math.min(Math.max(w, MEMORY_LIST_MIN_WIDTH), max);
-}
-
-/** True on md+ viewports (matches Tailwind's `md:` breakpoint = 768px). Starts
- * false (SSR-safe) and corrects on mount. */
-function useIsDesktop(): boolean {
- const [isDesktop, setIsDesktop] = useState(false);
- useEffect(() => {
- const mq = window.matchMedia("(min-width: 768px)");
- const update = () => setIsDesktop(mq.matches);
- update();
- mq.addEventListener("change", update);
- return () => mq.removeEventListener("change", update);
- }, []);
- return isDesktop;
-}
export function MemoryPanel() {
- const [listing, setListing] = useState(null);
- const [loading, setLoading] = useState(true);
- const [error, setError] = useState(null);
+ const [listing, setListing] = useState<{
+ entries: Array<{
+ path: string;
+ size: number;
+ mtime: number;
+ editable: boolean;
+ }>;
+ } | null>(null);
+ const [listingLoading, setListingLoading] = useState(true);
- const [selected, setSelected] = useState(null);
- const [file, setFile] = useState(null);
- const [fileLoading, setFileLoading] = useState(false);
- const [fileError, setFileError] = useState(null);
+ const [activeTab, setActiveTab] = useState<
+ "identity" | "knowledge" | "history"
+ >("identity");
- const [editing, setEditing] = useState(false);
- const [draft, setDraft] = useState("");
- const [saving, setSaving] = useState(false);
- const [copied, setCopied] = useState(false);
+ const [obsData, setObsData] = useState(null);
+ const [obsLoading, setObsLoading] = useState(false);
+ const [obsError, setObsError] = useState(null);
- const [newOpen, setNewOpen] = useState(false);
- const [newName, setNewName] = useState("");
- const [newBusy, setNewBusy] = useState(false);
- const [newError, setNewError] = useState(null);
- const [deleteOpen, setDeleteOpen] = useState(false);
- const [deleteBusy, setDeleteBusy] = useState(false);
- const [discardOpen, setDiscardOpen] = useState(false);
+ const [execData, setExecData] = useState<{
+ entries: ExecEntryClient[];
+ truncated: boolean;
+ } | null>(null);
+ const [execLoading, setExecLoading] = useState(false);
+ const [execError, setExecError] = useState(null);
- const didAutoSelect = useRef(false);
- // Monotonic id so a slow file fetch can't overwrite a newer selection.
- const reqRef = useRef(0);
- const pendingDiscardActionRef = useRef<(() => void) | null>(null);
+ const [highlightObsId, setHighlightObsId] = useState(null);
- // --- Resizable file-list / viewer split (desktop only) -------------------
- const isDesktop = useIsDesktop();
- const splitContainerRef = useRef(null);
- const dragCleanupRef = useRef<(() => void) | null>(null);
- const roRef = useRef(null);
- const [listWidth, setListWidth] = useState(() => {
- if (typeof window === "undefined") return MEMORY_LIST_DEFAULT_WIDTH;
- const saved = Number(window.localStorage.getItem(MEMORY_LIST_WIDTH_KEY));
- return Number.isFinite(saved) && saved >= MEMORY_LIST_MIN_WIDTH
- ? saved
- : MEMORY_LIST_DEFAULT_WIDTH;
- });
- useEffect(() => {
- try {
- window.localStorage.setItem(MEMORY_LIST_WIDTH_KEY, String(listWidth));
- } catch {
- // localStorage unavailable (e.g. private mode) — width just won't persist.
- }
- }, [listWidth]);
- // Callback ref for the split container: attach a ResizeObserver the moment it
- // mounts (the list renders only after data loads, so a plain effect could run
- // before the node exists). The observer fires once on observe — clamping a
- // persisted width too wide for the current window — and again on every resize.
- // Desktop-only (the width isn't applied on mobile); disconnects on unmount.
- const setSplitContainer = useCallback((node: HTMLDivElement | null) => {
- splitContainerRef.current = node;
- roRef.current?.disconnect();
- roRef.current = null;
- if (node && typeof ResizeObserver !== "undefined") {
- const ro = new ResizeObserver(() => {
- if (window.matchMedia("(min-width: 768px)").matches) {
- setListWidth((w) => clampListWidth(w, node));
- }
- });
- ro.observe(node);
- roRef.current = ro;
- }
- }, []);
- // Tear down an in-progress drag if the component unmounts mid-drag.
- useEffect(() => () => dragCleanupRef.current?.(), []);
- const onDividerPointerDown = useCallback(
- (e: ReactPointerEvent) => {
- e.preventDefault();
- dragCleanupRef.current?.(); // defensive: end any stray prior drag
- const startX = e.clientX;
- const startW = listWidth;
- const controller = new AbortController();
- const cleanup = () => {
- controller.abort(); // removes every listener added with this signal
- document.body.style.removeProperty("cursor");
- document.body.style.removeProperty("user-select");
- dragCleanupRef.current = null;
- };
- const move = (ev: PointerEvent) => {
- setListWidth(
- clampListWidth(
- startW + (ev.clientX - startX),
- splitContainerRef.current
- )
- );
- };
- const opts = { signal: controller.signal };
- // Track on window so the drag continues even if the pointer leaves the thin
- // handle; pointercancel / blur also end it so the global cursor + selection
- // lock and the listeners never get stuck.
- window.addEventListener("pointermove", move, opts);
- window.addEventListener("pointerup", cleanup, opts);
- window.addEventListener("pointercancel", cleanup, opts);
- window.addEventListener("blur", cleanup, opts);
- document.body.style.cursor = "col-resize";
- document.body.style.userSelect = "none";
- dragCleanupRef.current = cleanup;
- },
- [listWidth]
- );
+ const obsReqRef = useRef(0);
+ const execReqRef = useRef(0);
- const load = useCallback(async () => {
- setLoading(true);
- setError(null);
+ const loadListing = useCallback(async () => {
+ setListingLoading(true);
try {
const res = await fetch("/api/memory");
- const data = await res.json();
- if (!res.ok) throw new Error(data.error || "Failed to load memory.");
- const listing = data as MemoryListing;
- setListing(listing);
- // Opening the panel = the user has now seen the current memory, so clear
- // the nav badge (covers URL-direct navigation, not just the nav click).
- const latest = listing.entries.reduce(
+ const data = (await res.json()) as {
+ entries: Array<{
+ path: string;
+ size: number;
+ mtime: number;
+ editable: boolean;
+ }>;
+ };
+ if (!res.ok) return;
+ setListing(data);
+ const latest = data.entries.reduce(
(max, e) => (e.mtime > max ? e.mtime : max),
0
);
if (latest > 0) setMemorySeenAt(latest);
- } catch (e) {
- setError(e instanceof Error ? e.message : "Failed to load memory.");
+ } catch {
+ void 0;
} finally {
- setLoading(false);
+ setListingLoading(false);
+ }
+ }, []);
+
+ const loadObservations = useCallback(async () => {
+ const reqId = ++obsReqRef.current;
+ setObsLoading(true);
+ setObsError(null);
+ try {
+ const res = await fetch("/api/memory/observations", {
+ cache: "no-store",
+ });
+ const data = await res.json().catch(() => ({}));
+ if (reqId !== obsReqRef.current) return;
+ if (!res.ok)
+ throw new Error((data as { error?: string }).error || "Failed.");
+ setObsData(data as ObsGraphData);
+ } catch (e) {
+ if (reqId !== obsReqRef.current) return;
+ setObsError(e instanceof Error ? e.message : "Failed to load.");
+ } finally {
+ if (reqId === obsReqRef.current) setObsLoading(false);
+ }
+ }, []);
+
+ const loadExecutions = useCallback(async () => {
+ const reqId = ++execReqRef.current;
+ setExecLoading(true);
+ setExecError(null);
+ try {
+ const res = await fetch("/api/memory/executions", { cache: "no-store" });
+ const data = await res.json().catch(() => ({}));
+ if (reqId !== execReqRef.current) return;
+ if (!res.ok)
+ throw new Error((data as { error?: string }).error || "Failed.");
+ setExecData(data as { entries: ExecEntryClient[]; truncated: boolean });
+ } catch (e) {
+ if (reqId !== execReqRef.current) return;
+ setExecError(e instanceof Error ? e.message : "Failed to load.");
+ } finally {
+ if (reqId === execReqRef.current) setExecLoading(false);
}
}, []);
useEffect(() => {
- load();
- }, [load]);
+ void loadListing();
+ }, [loadListing]);
- // Tick for relative "updated Xm ago" labels.
- const [now, setNow] = useState(() => Date.now());
useEffect(() => {
- const id = setInterval(() => setNow(Date.now()), 60_000);
+ if (activeTab !== "history") return;
+ const refresh = () => {
+ void loadExecutions();
+ void loadObservations();
+ };
+ refresh();
+ const id = setInterval(refresh, 30_000);
return () => clearInterval(id);
- }, []);
+ }, [activeTab, loadExecutions, loadObservations]);
- // Group + sort the file list for the sidebar.
- const groups = useMemo(() => {
- const entries = listing?.entries ?? [];
- const byGroup = new Map();
- for (const e of entries) {
- const g = groupOf(e.path);
- (byGroup.get(g) ?? byGroup.set(g, []).get(g)!).push(e);
+ const timelineItems = useMemo(() => {
+ if (!execData && !obsData) return null;
+ const items: TimelineItem[] = [];
+ for (const e of execData?.entries ?? []) {
+ items.push({ kind: "execution", ...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 }),
+ for (const n of obsData?.nodes ?? []) {
+ items.push({
+ kind: "observation",
+ id: n.id,
+ created_at: n.created_at,
+ summary: n.summary,
+ memory_type: n.memory_type,
+ scope: n.scope,
});
- 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);
}
+ items.sort((a, b) => (a.created_at < b.created_at ? 1 : -1));
+ return items;
+ }, [execData, obsData]);
+
+ const TABS = [
+ { id: "identity" as const, label: "Identity", Icon: Fingerprint },
+ { id: "knowledge" as const, label: "Knowledge", Icon: Network },
+ { id: "history" as const, label: "History", Icon: History },
+ ];
+
+ const handleTabClick = (id: "identity" | "knowledge" | "history") => {
+ setActiveTab(id);
+ if (id === "knowledge" && !obsData && !obsLoading) loadObservations();
+ if (id !== "knowledge") setHighlightObsId(null);
};
- const confirmDelete = async () => {
- if (!selected) return;
- const reqId = reqRef.current; // detect a file switch during the await
- setDeleteBusy(true);
- 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);
- }
- setDeleteOpen(false);
- load();
- } catch (e) {
- if (reqId === reqRef.current) {
- setFileError(e instanceof Error ? e.message : "Failed to delete.");
- }
- } finally {
- setDeleteBusy(false);
- }
+ const handleNavigateToObs = (obsId: string) => {
+ setHighlightObsId(obsId);
+ setActiveTab("knowledge");
+ if (!obsData && !obsLoading) loadObservations();
};
- const copy = async () => {
- if (!file) return;
- if (await copyText(file.content)) {
- setCopied(true);
- setTimeout(() => setCopied(false), 1500);
- }
- };
-
- 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 runOrConfirmDiscard = (action: () => void) => {
- if (!dirty) {
- action();
- return;
- }
- pendingDiscardActionRef.current = action;
- setDiscardOpen(true);
- };
-
- const confirmDiscardChanges = () => {
- const action = pendingDiscardActionRef.current;
- pendingDiscardActionRef.current = null;
- setDiscardOpen(false);
- setEditing(false);
- action?.();
- };
-
- const ext = selected ? extOf(fileNameOf(selected)) : "";
- const isMarkdown = ext === "md" || ext === "markdown";
-
return (
@@ -487,499 +180,107 @@ export function MemoryPanel() {
/>
EvoMemory
-
+
EvoScientist's{" "}
self-evolving memory system
{" "}
- — it continuously learns and refines what it knows about you, your
- research taste, and lessons from past experiments, updating itself
- after every turn. View or edit it directly.
+ — continuously learns and refines what it knows about you, your
+ research taste, and lessons from past experiments.