diff --git a/src/app/api/memory/executions/route.ts b/src/app/api/memory/executions/route.ts new file mode 100644 index 0000000..5533fa6 --- /dev/null +++ b/src/app/api/memory/executions/route.ts @@ -0,0 +1,29 @@ +import { type NextRequest, NextResponse } from "next/server"; +import { isCrossOrigin, listExecutions } from "@/lib/server/memory"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +const NO_STORE_HEADERS = { "Cache-Control": "no-store" }; + +export async function GET(request: NextRequest) { + try { + if (isCrossOrigin(request)) { + return NextResponse.json( + { error: "Cross-origin access not allowed." }, + { status: 403, headers: NO_STORE_HEADERS } + ); + } + return NextResponse.json(await listExecutions(), { + headers: NO_STORE_HEADERS, + }); + } catch (error) { + return NextResponse.json( + { + error: + error instanceof Error ? error.message : "Failed to load executions.", + }, + { status: 400, headers: NO_STORE_HEADERS } + ); + } +} diff --git a/src/app/api/memory/observations/route.ts b/src/app/api/memory/observations/route.ts new file mode 100644 index 0000000..0d2c8ce --- /dev/null +++ b/src/app/api/memory/observations/route.ts @@ -0,0 +1,34 @@ +import { type NextRequest, NextResponse } from "next/server"; + +import { isCrossOrigin, listObservations } from "@/lib/server/memory"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +const NO_STORE_HEADERS = { + "Cache-Control": "no-store", +}; + +export async function GET(request: NextRequest) { + try { + if (isCrossOrigin(request)) { + return NextResponse.json( + { error: "Cross-origin access not allowed." }, + { status: 403, headers: NO_STORE_HEADERS } + ); + } + return NextResponse.json(await listObservations(), { + headers: NO_STORE_HEADERS, + }); + } catch (error) { + return NextResponse.json( + { + error: + error instanceof Error + ? error.message + : "Failed to load observations.", + }, + { status: 400, headers: NO_STORE_HEADERS } + ); + } +} diff --git a/src/app/components/HistoryTab.tsx b/src/app/components/HistoryTab.tsx new file mode 100644 index 0000000..d4bcc0e --- /dev/null +++ b/src/app/components/HistoryTab.tsx @@ -0,0 +1,355 @@ +"use client"; + +import { useState } from "react"; +import { Loader2, RefreshCw, ChevronDown, Network } from "lucide-react"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { nodeColor } from "@/lib/observationGraph"; + +export interface ExecEntryClient { + id: string; + created_at: string; + agent: string; + session_id: string; + project_id: string; + summary: string; + obs_ids: string[]; + path: string; +} + +export interface ObsItemClient { + id: string; + created_at: string; + summary: string; + memory_type: string; + scope: string; +} + +export type TimelineItem = + | ({ kind: "execution" } & ExecEntryClient) + | ({ kind: "observation" } & ObsItemClient); + +interface HistoryTabProps { + items: TimelineItem[] | null; + truncated: boolean; + loading: boolean; + error: string | null; + onRefresh: () => void; + onNavigateToObs: (obsId: string) => void; +} + +function formatTimelineStamp(iso: string): string { + try { + const d = new Date(iso); + if (isToday(iso)) { + return new Intl.DateTimeFormat(undefined, { + hour: "2-digit", + minute: "2-digit", + hourCycle: "h23", + }).format(d); + } + const now = new Date(); + return new Intl.DateTimeFormat(undefined, { + month: "short", + day: "numeric", + ...(d.getFullYear() !== now.getFullYear() ? { year: "numeric" } : {}), + }).format(d); + } catch { + return iso; + } +} + +function isToday(iso: string): boolean { + try { + const d = new Date(iso); + const now = new Date(); + return ( + d.getFullYear() === now.getFullYear() && + d.getMonth() === now.getMonth() && + d.getDate() === now.getDate() + ); + } catch { + return false; + } +} + +function StatsBar({ items }: { items: TimelineItem[] }) { + const projectCount = new Set( + items + .filter((i) => i.kind === "execution") + .map((i) => (i as ExecEntryClient).project_id) + .filter(Boolean) + ).size; + const today = items.filter((i) => i.created_at && isToday(i.created_at)); + const runsToday = today.filter((i) => i.kind === "execution").length; + const obsToday = today.filter((i) => i.kind === "observation").length; + + const stats = [ + { n: projectCount, label: "Projects" }, + { n: items.length, label: "Total" }, + { n: runsToday, label: "Runs Today" }, + { n: obsToday, label: "Obs Today" }, + ]; + + return ( +
+ {stats.map((s) => ( +
+ + {s.n} + + + {s.label} + +
+ ))} +
+ ); +} + +function TimelineRow({ + created_at, + dotColor, + children, +}: { + created_at: string; + dotColor: string; + children: React.ReactNode; +}) { + return ( +
+ + +
+
+ + {children} +
+ ); +} + +function EntryCard({ + entry, + onNavigateToObs, +}: { + entry: ExecEntryClient; + onNavigateToObs: (id: string) => void; +}) { + const [open, setOpen] = useState(false); + const toggle = () => setOpen((o) => !o); + const label = `${open ? "Collapse" : "Expand"} ${entry.agent} execution: ${ + entry.summary + }`; + + return ( + +
+ + +
+ {open && ( +
+ {entry.project_id && ( +

+ {entry.project_id} +

+ )} +

+ {entry.summary} +

+ {entry.obs_ids.length > 0 && ( +
+

+ Linked Observations +

+
+ {entry.obs_ids.map((id) => ( + + ))} +
+
+ )} +
+ )} +
+
+
+ ); +} + +function ObsCard({ + obs, + onNavigateToObs, +}: { + obs: ObsItemClient; + onNavigateToObs: (id: string) => void; +}) { + const color = nodeColor(obs.memory_type); + + return ( + + + + ); +} + +export function HistoryTab({ + items, + truncated, + loading, + error, + onRefresh, + onNavigateToObs, +}: HistoryTabProps) { + if (loading && !items) { + return ( +
+
+ ); + } + if (error) { + return ( +
+

{error}

+ +
+ ); + } + if (!items || items.length === 0) { + return ( +
+ No activity yet. +
+ ); + } + + return ( +
+ + +
+
+ {items.map((item) => + item.kind === "execution" ? ( + + ) : ( + + ) + )} +
+ {truncated && ( +

+ Showing newest {items.length} items. +

+ )} +
+
+
+ ); +} diff --git a/src/app/components/IdentityTab.tsx b/src/app/components/IdentityTab.tsx new file mode 100644 index 0000000..066b17d --- /dev/null +++ b/src/app/components/IdentityTab.tsx @@ -0,0 +1,399 @@ +"use client"; + +import { useEffect, useMemo, useRef, useState } from "react"; +import { + type LucideIcon, + Compass, + FolderOpen, + Loader2, + Pencil, + Sparkles, + User, +} from "lucide-react"; +import { MarkdownContent } from "@/app/components/MarkdownContent"; +import { Button } from "@/components/ui/button"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { + ResizableHandle, + ResizablePanel, + ResizablePanelGroup, +} from "@/components/ui/resizable"; +import { cn } from "@/lib/utils"; + +interface IdentityTabProps { + listing: { entries: Array<{ path: string }> } | null; + listingLoading: boolean; +} + +interface ProfileEntry { + path: string; + label: string; + description: string; + Icon: LucideIcon; +} + +const CORE_ENTRIES: ProfileEntry[] = [ + { + path: "profile/SOUL.md", + label: "SOUL", + description: "EvoScientist's core values and personality", + Icon: Sparkles, + }, + { + path: "profile/USER_PROFILE.md", + label: "User Profile", + description: "Persistent context about the researcher", + Icon: User, + }, + { + path: "profile/RESEARCH_TASTE.md", + label: "Research Taste", + description: "Aesthetic and methodological preferences", + Icon: Compass, + }, +]; + +function ProfileRow({ + entry, + selected, + onSelect, +}: { + entry: ProfileEntry; + selected: boolean; + onSelect: (e: ProfileEntry) => void; +}) { + const { Icon } = entry; + + return ( + + ); +} + +function ContentPanel({ entry }: { entry: ProfileEntry | null }) { + const [content, setContent] = useState(""); + const [draft, setDraft] = useState(""); + const [loading, setLoading] = useState(false); + const [editing, setEditing] = useState(false); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const reqRef = useRef(0); + + useEffect(() => { + if (!entry) { + setContent(""); + setDraft(""); + return; + } + const reqId = ++reqRef.current; + setLoading(true); + setEditing(false); + setError(null); + fetch(`/api/memory?path=${encodeURIComponent(entry.path)}`) + .then(async (r) => { + const d = (await r.json().catch(() => ({}))) as { + content?: string; + error?: string; + }; + if (!r.ok) { + throw new Error(d.error || "Failed to load."); + } + return d; + }) + .then((d) => { + if (reqRef.current !== reqId) return; + setContent(d.content ?? ""); + setDraft(d.content ?? ""); + }) + .catch((e: unknown) => { + if (reqRef.current !== reqId) return; + setContent(""); + setDraft(""); + setError(e instanceof Error ? e.message : "Failed to load."); + }) + .finally(() => { + if (reqRef.current === reqId) setLoading(false); + }); + }, [entry]); + + const save = async () => { + if (!entry) return; + setSaving(true); + setError(null); + try { + const res = await fetch("/api/memory", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ path: entry.path, content: draft }), + }); + const d = (await res.json()) as { error?: string }; + if (!res.ok) throw new Error(d.error ?? "Failed to save."); + setContent(draft); + setEditing(false); + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to save."); + } finally { + setSaving(false); + } + }; + + if (!entry) { + return ( +
+ Select a profile to view +
+ ); + } + + const body = content.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, "").trim(); + + return ( +
+
+ {entry.label} +
+ {editing ? ( + <> + + + + ) : ( + + )} +
+
+ + {error && ( +

+ {error} +

+ )} + +
+ {loading ? ( +
+
+ ) : editing ? ( +