feat: add observation graph component with force simulation and detail panel

- Implemented the ObservationGraph component to visualize relationships between observations.
- Added force simulation for node positioning using physics-based calculations.
- Created a detail panel to display information about selected nodes, including related observations.
- Introduced utility functions for managing observation graph data, including node and edge definitions.
- Enhanced memory reading functions to extract and structure observation data for the graph.
This commit is contained in:
Xi Zhang
2026-06-27 19:07:55 +01:00
parent cfa3ed468a
commit a12c664a3d
8 changed files with 2326 additions and 917 deletions
+29
View File
@@ -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 }
);
}
}
+34
View File
@@ -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 }
);
}
}
+355
View File
@@ -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 (
<div className="flex flex-shrink-0 border-b border-border">
{stats.map((s) => (
<div
key={s.label}
className="flex flex-1 flex-col items-center gap-1 border-r border-border py-2 last:border-r-0"
>
<span className="text-lg font-bold leading-none text-[var(--brand)]">
{s.n}
</span>
<span className="text-[11px] leading-none text-muted-foreground">
{s.label}
</span>
</div>
))}
</div>
);
}
function TimelineRow({
created_at,
dotColor,
children,
}: {
created_at: string;
dotColor: string;
children: React.ReactNode;
}) {
return (
<div className="flex gap-2">
<time
dateTime={created_at}
className="w-14 flex-shrink-0 pt-3 text-right text-xs tabular-nums leading-none text-muted-foreground"
>
{formatTimelineStamp(created_at)}
</time>
<div className="relative flex w-3 flex-shrink-0 justify-center">
<span
className="absolute bottom-0 left-1/2 top-0 w-px -translate-x-1/2 bg-border"
aria-hidden="true"
/>
<span
className="z-10 mt-3 size-2.5 rounded-full border-2 border-[var(--color-surface)]"
style={{ background: dotColor }}
/>
</div>
{children}
</div>
);
}
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 (
<TimelineRow
created_at={entry.created_at}
dotColor="var(--brand)"
>
<div className="hover:border-[var(--brand)]/40 mb-2 min-w-0 flex-1 overflow-hidden rounded-lg border border-border bg-[var(--color-surface)] transition-colors">
<button
type="button"
aria-expanded={open}
aria-label={label}
title={label}
onClick={toggle}
className="flex w-full items-center gap-2 px-3 py-2.5 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
>
<span className="flex-shrink-0 text-sm font-medium text-foreground">
{entry.agent}
</span>
{!open && (
<span className="min-w-0 flex-1 truncate text-xs text-muted-foreground">
{entry.summary}
</span>
)}
<ChevronDown
className={`ml-auto size-3.5 flex-shrink-0 text-muted-foreground transition-transform duration-200 ${
open ? "rotate-180" : ""
}`}
aria-hidden="true"
/>
</button>
<div
className="grid transition-[grid-template-rows] duration-200"
style={{ gridTemplateRows: open ? "1fr" : "0fr" }}
>
{open && (
<div className="overflow-hidden">
{entry.project_id && (
<p className="px-3 pb-2 font-mono text-[11px] text-muted-foreground">
{entry.project_id}
</p>
)}
<p className="border-t border-border px-3 py-3 text-sm leading-relaxed text-foreground">
{entry.summary}
</p>
{entry.obs_ids.length > 0 && (
<div className="border-t border-border px-3 py-2.5">
<p className="mb-2 text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">
Linked Observations
</p>
<div className="flex flex-wrap gap-1.5">
{entry.obs_ids.map((id) => (
<button
key={id}
type="button"
onClick={(e) => {
e.stopPropagation();
onNavigateToObs(id);
}}
className="border-[var(--brand)]/40 rounded border bg-background px-2 py-0.5 font-mono text-[11px] text-[var(--brand)] transition-colors hover:bg-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
title={`Jump to ${id} in Knowledge graph`}
aria-label={`Jump to observation ${id} in Knowledge graph`}
>
{id.slice(0, 14)}…
</button>
))}
</div>
</div>
)}
</div>
)}
</div>
</div>
</TimelineRow>
);
}
function ObsCard({
obs,
onNavigateToObs,
}: {
obs: ObsItemClient;
onNavigateToObs: (id: string) => void;
}) {
const color = nodeColor(obs.memory_type);
return (
<TimelineRow
created_at={obs.created_at}
dotColor={color}
>
<button
type="button"
onClick={() => onNavigateToObs(obs.id)}
title="View in Knowledge graph"
aria-label={`View observation in Knowledge graph: ${obs.summary}`}
className="hover:border-[var(--brand)]/40 mb-2 min-w-0 flex-1 cursor-pointer overflow-hidden rounded-lg border border-dashed border-border bg-[var(--color-surface)] px-3 py-2.5 text-left transition-colors hover:bg-accent/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<div className="flex items-center gap-2">
<span
className="flex-shrink-0 rounded px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-white"
style={{ background: color }}
>
{obs.memory_type}
</span>
<span className="min-w-0 flex-1 truncate text-xs text-foreground">
{obs.summary}
</span>
<Network
className="ml-auto size-3.5 flex-shrink-0 text-muted-foreground"
aria-hidden="true"
/>
</div>
</button>
</TimelineRow>
);
}
export function HistoryTab({
items,
truncated,
loading,
error,
onRefresh,
onNavigateToObs,
}: HistoryTabProps) {
if (loading && !items) {
return (
<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 history…
</div>
);
}
if (error) {
return (
<div className="flex flex-1 flex-col items-center justify-center gap-3 p-8">
<p className="text-sm text-destructive">{error}</p>
<button
type="button"
onClick={onRefresh}
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground"
>
<RefreshCw
className="size-3.5"
aria-hidden="true"
/>
Retry
</button>
</div>
);
}
if (!items || items.length === 0) {
return (
<div className="flex flex-1 items-center justify-center text-sm text-muted-foreground">
No activity yet.
</div>
);
}
return (
<div className="flex h-full min-h-0 flex-col">
<StatsBar items={items} />
<ScrollArea
type="always"
className="min-h-0 flex-1 [&_[data-slot=scroll-area-viewport]>div]:!block [&_[data-slot=scroll-area-viewport]>div]:!w-full"
>
<div className="p-4">
<div>
{items.map((item) =>
item.kind === "execution" ? (
<EntryCard
key={`e-${item.id}`}
entry={item}
onNavigateToObs={onNavigateToObs}
/>
) : (
<ObsCard
key={`o-${item.id}`}
obs={item}
onNavigateToObs={onNavigateToObs}
/>
)
)}
</div>
{truncated && (
<p className="mt-2 text-center text-xs text-muted-foreground">
Showing newest {items.length} items.
</p>
)}
</div>
</ScrollArea>
</div>
);
}
+399
View File
@@ -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 (
<button
type="button"
onClick={() => onSelect(entry)}
className={cn(
"group flex w-full min-w-0 items-center gap-2 rounded-md border px-3 py-2.5 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
selected
? "border-[var(--brand)]/40 bg-accent"
: "hover:border-[var(--brand)]/40 border-border bg-[var(--color-surface)] hover:bg-accent/60"
)}
>
<span
className={cn(
"flex size-7 shrink-0 items-center justify-center rounded-md transition-colors",
selected
? "bg-[var(--brand)] text-white"
: "bg-muted text-[var(--brand)] group-hover:bg-background"
)}
>
<Icon
className="size-4"
aria-hidden="true"
/>
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium">
{entry.label}
</span>
<span className="block truncate text-[11px] text-muted-foreground">
{entry.description}
</span>
</span>
</button>
);
}
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<string | null>(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 (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
Select a profile to view
</div>
);
}
const body = content.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, "").trim();
return (
<div className="flex h-full flex-col overflow-hidden">
<div className="flex flex-shrink-0 items-center justify-between border-b border-border px-4 py-2.5">
<span className="text-sm font-medium">{entry.label}</span>
<div className="flex items-center gap-1.5">
{editing ? (
<>
<Button
variant="outline"
size="sm"
onClick={() => {
setEditing(false);
setDraft(content);
}}
>
Cancel
</Button>
<Button
size="sm"
onClick={save}
disabled={saving || draft === content}
>
{saving ? (
<>
<Loader2
className="size-3.5 animate-spin"
aria-hidden="true"
/>
Saving
</>
) : (
"Save"
)}
</Button>
</>
) : (
<Button
variant="outline"
size="sm"
onClick={() => setEditing(true)}
disabled={loading}
>
<Pencil
className="size-3.5"
aria-hidden="true"
/>
Edit raw
</Button>
)}
</div>
</div>
{error && (
<p className="flex-shrink-0 px-4 py-2 text-xs text-destructive">
{error}
</p>
)}
<div className="min-h-0 flex-1 overflow-hidden">
{loading ? (
<div className="flex h-full items-center justify-center gap-2 text-sm text-muted-foreground">
<Loader2
className="size-4 animate-spin"
aria-hidden="true"
/>
Loading…
</div>
) : editing ? (
<textarea
aria-label={`Edit ${entry.label} memory`}
className="size-full resize-none bg-background p-4 font-mono text-sm text-foreground focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
value={draft}
onChange={(e) => setDraft(e.target.value)}
spellCheck={false}
/>
) : (
<ScrollArea className="h-full">
<div className="px-4 py-4">
<MarkdownContent content={body} />
</div>
</ScrollArea>
)}
</div>
</div>
);
}
export function IdentityTab({ listing, listingLoading }: IdentityTabProps) {
const [selected, setSelected] = useState<ProfileEntry | null>(null);
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);
}, []);
const projectEntries: ProfileEntry[] = useMemo(
() =>
(listing?.entries ?? [])
.filter(
(e) =>
e.path.startsWith("profile/projects/") && e.path.endsWith(".md")
)
.map((e) => {
const segments = e.path.split("/");
const id =
segments.length >= 4
? `${segments[2]}/${segments.slice(3).join("/")}`
: e.path;
return {
path: e.path,
label: id.replace(/\/PROJECT_PROFILE\.md$/, ""),
description: "Project profile",
Icon: FolderOpen,
};
}),
[listing?.entries]
);
const entries = useMemo(
() => [...CORE_ENTRIES, ...projectEntries],
[projectEntries]
);
useEffect(() => {
if (listingLoading || entries.length === 0) return;
if (!selected || !entries.some((entry) => entry.path === selected.path)) {
setSelected(entries[0]);
}
}, [entries, listingLoading, selected]);
const sidebar = (
<ScrollArea className="h-full">
<div className="p-3">
<p className="mb-2 text-[10px] font-bold uppercase tracking-wider text-muted-foreground">
Profile
</p>
<div className="space-y-1.5">
{CORE_ENTRIES.map((entry) => (
<ProfileRow
key={entry.path}
entry={entry}
selected={selected?.path === entry.path}
onSelect={setSelected}
/>
))}
</div>
<p className="mb-2 mt-5 text-[10px] font-bold uppercase tracking-wider text-muted-foreground">
Project Profiles
</p>
{listingLoading ? (
<div className="flex h-10 items-center gap-2 text-xs text-muted-foreground">
<Loader2
className="size-3.5 animate-spin"
aria-hidden="true"
/>
Loading projects…
</div>
) : projectEntries.length === 0 ? (
<p className="text-xs text-muted-foreground">
No project profiles yet.
</p>
) : (
<div className="space-y-1.5">
{projectEntries.map((entry) => (
<ProfileRow
key={entry.path}
entry={entry}
selected={selected?.path === entry.path}
onSelect={setSelected}
/>
))}
</div>
)}
</div>
</ScrollArea>
);
if (!isDesktop) {
return (
<div className="flex min-h-0 w-full flex-1 flex-col">
<div className="max-h-[42svh] flex-shrink-0 overflow-hidden border-b border-border">
{sidebar}
</div>
<div className="min-h-0 flex-1">
<ContentPanel entry={selected} />
</div>
</div>
);
}
return (
<ResizablePanelGroup
direction="horizontal"
autoSaveId="evomemory-identity"
className="h-full"
>
<ResizablePanel
defaultSize={35}
minSize={22}
maxSize={55}
>
{sidebar}
</ResizablePanel>
<ResizableHandle />
<ResizablePanel
defaultSize={65}
minSize={45}
>
<ContentPanel entry={selected} />
</ResizablePanel>
</ResizablePanelGroup>
);
}
File diff suppressed because it is too large Load Diff
+915
View File
@@ -0,0 +1,915 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
initPositions,
nodeColor,
nodeRadius,
relationColor,
relationLabel,
tickSimulation,
type NodePos,
type ObsEdge,
type ObsGraphData,
type ObsNode,
} from "@/lib/observationGraph";
import { X, ZoomIn, ZoomOut, Maximize2 } from "lucide-react";
import { MarkdownContent } from "@/app/components/MarkdownContent";
// ---------------------------------------------------------------------------
// Force simulation hook
// ---------------------------------------------------------------------------
const MAX_TICKS = 600;
const ENERGY_THRESHOLD = 0.3;
const GRAPH_BUTTON_CLASS =
"rounded border border-border bg-background p-1.5 text-muted-foreground shadow-sm transition-colors hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50";
const ALL_RELATIONS = ["complements", "contradicts", "supersedes"] as const;
type RelationType = (typeof ALL_RELATIONS)[number];
function stripFrontMatter(content: string): string {
return content.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, "").trim();
}
function useForceSimulation(
nodes: ObsNode[],
edges: ObsEdge[],
w: number,
h: number
) {
const [positions, setPositions] = useState<NodePos[]>([]);
const posRef = useRef<NodePos[]>([]);
const rafRef = useRef<number | null>(null);
const tickRef = useRef(0);
const wRef = useRef(w);
const hRef = useRef(h);
useEffect(() => {
wRef.current = w;
hRef.current = h;
}, [w, h]);
// Re-initialise only when the graph data changes — NOT on resize, so opening
// or dragging the detail panel never restarts the layout.
useEffect(() => {
if (nodes.length === 0 || wRef.current === 0 || hRef.current === 0) {
setPositions([]);
posRef.current = [];
return;
}
const init = initPositions(nodes, wRef.current, hRef.current);
posRef.current = init;
setPositions(init);
tickRef.current = 0;
if (rafRef.current) cancelAnimationFrame(rafRef.current);
let frame = 0;
const animate = () => {
const { positions: next, energy } = tickSimulation(
posRef.current,
edges,
wRef.current,
hRef.current
);
posRef.current = next;
tickRef.current++;
frame++;
if (frame % 2 === 0) {
setPositions([...next]);
}
if (tickRef.current < MAX_TICKS && energy > ENERGY_THRESHOLD) {
rafRef.current = requestAnimationFrame(animate);
} else {
setPositions([...posRef.current]);
}
};
rafRef.current = requestAnimationFrame(animate);
return () => {
if (rafRef.current) cancelAnimationFrame(rafRef.current);
};
}, [nodes, edges]);
return positions;
}
// ---------------------------------------------------------------------------
// Detail panel
// ---------------------------------------------------------------------------
interface DetailPanelProps {
node: ObsNode;
edges: ObsEdge[];
nodeMap: Map<string, ObsNode>;
onClose: () => void;
onNavigate: (id: string) => void;
}
function NodeDetailPanel({
node,
edges,
nodeMap,
onClose,
onNavigate,
}: DetailPanelProps) {
const [content, setContent] = useState<string | null>(null);
const [loadingContent, setLoadingContent] = useState(true);
const [contentError, setContentError] = useState<string | null>(null);
useEffect(() => {
const controller = new AbortController();
setLoadingContent(true);
setContent(null);
setContentError(null);
fetch(`/api/memory?path=${encodeURIComponent(node.path)}`, {
cache: "no-store",
signal: controller.signal,
})
.then(async (res) => {
const data = (await res.json().catch(() => ({}))) as {
content?: string;
error?: string;
};
if (!res.ok) {
throw new Error(data.error || "Failed to load observation.");
}
setContent(data.content ?? null);
})
.catch((error) => {
if (controller.signal.aborted) return;
setContent(null);
setContentError(
error instanceof Error ? error.message : "Failed to load observation."
);
})
.finally(() => {
if (!controller.signal.aborted) setLoadingContent(false);
});
return () => controller.abort();
}, [node.path]);
// Edges involving this node.
const relatedEdges = edges.filter(
(e) => e.source === node.id || e.target === node.id
);
const displayContent = content ? stripFrontMatter(content) : "";
const formatDate = (iso: string) => {
try {
const date = new Date(iso);
if (Number.isNaN(date.getTime())) return iso;
return date.toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
});
} catch {
return iso;
}
};
return (
<div className="flex h-full flex-col overflow-hidden border-l border-border bg-background shadow-xl md:shadow-none">
{/* Header */}
<div className="flex items-start justify-between gap-2 border-b border-border px-4 py-3">
<div className="min-w-0 flex-1">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
{node.memory_type} · {node.scope}
</p>
<h3 className="mt-0.5 line-clamp-3 text-sm font-semibold leading-snug text-foreground">
{node.summary}
</h3>
{node.created_at && (
<p className="mt-1 text-xs text-muted-foreground">
{formatDate(node.created_at)}
</p>
)}
</div>
<button
type="button"
onClick={onClose}
className="mt-0.5 inline-flex size-8 flex-shrink-0 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring"
aria-label="Close observation details"
title="Close observation details"
>
<X
className="h-4 w-4"
aria-hidden="true"
/>
</button>
</div>
<div
className="flex-1 overflow-y-auto"
aria-live={loadingContent ? "polite" : "off"}
>
{/* Relations */}
{relatedEdges.length > 0 && (
<div className="border-b border-border px-4 py-3">
<p className="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
Relations
</p>
<ul className="space-y-1.5">
{relatedEdges.map((e, idx) => {
const otherId = e.source === node.id ? e.target : e.source;
const other = nodeMap.get(otherId);
return (
<li
key={idx}
className="flex items-start gap-2"
>
<span
className="mt-0.5 flex-shrink-0 rounded px-1.5 py-0.5 text-[10px] font-semibold text-white"
style={{ background: relationColor(e.relation) }}
>
{relationLabel(e.relation)}
</span>
{other ? (
<button
type="button"
onClick={() => onNavigate(otherId)}
className="min-w-0 rounded-sm text-left text-xs text-foreground hover:underline focus-visible:ring-2 focus-visible:ring-ring"
>
{other.summary}
</button>
) : (
<span className="text-xs text-muted-foreground">
{otherId}
</span>
)}
</li>
);
})}
</ul>
</div>
)}
{/* Content */}
<div className="px-4 py-3">
{loadingContent ? (
<p className="text-xs text-muted-foreground">Loading…</p>
) : contentError ? (
<p
role="alert"
className="text-xs text-destructive"
>
{contentError}
</p>
) : displayContent ? (
<MarkdownContent
content={displayContent}
className="text-xs leading-relaxed [&_h2]:mb-2 [&_h2]:mt-4 [&_h2]:text-sm [&_p]:mb-3"
/>
) : (
<p className="text-xs text-muted-foreground">
This observation has no body content.
</p>
)}
</div>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// SVG graph
// ---------------------------------------------------------------------------
interface GraphCanvasProps {
data: ObsGraphData;
selectedId: string | null;
onSelect: (id: string | null) => void;
w: number;
h: number;
}
function GraphCanvas({ data, selectedId, onSelect, w, h }: GraphCanvasProps) {
const positions = useForceSimulation(data.nodes, data.edges, w, h);
// Zoom / pan state.
const [tx, setTx] = useState(0);
const [ty, setTy] = useState(0);
const [scale, setScale] = useState(1);
const svgRef = useRef<SVGSVGElement>(null);
const dragRef = useRef<{
startX: number;
startY: number;
tx: number;
ty: number;
} | null>(null);
const [activeFilters, setActiveFilters] = useState<Set<string>>(
new Set(ALL_RELATIONS)
);
const [activeTypes, setActiveTypes] = useState<Set<string>>(
new Set(["semantic", "procedural"])
);
const posMap = useMemo(
() => new Map(positions.map((p) => [p.id, p])),
[positions]
);
const nodeMap = useMemo(
() => new Map(data.nodes.map((n) => [n.id, n])),
[data.nodes]
);
const viewRef = useRef({ tx, ty, scale });
viewRef.current = { tx, ty, scale };
const zoomAt = useCallback((px: number, py: number, factor: number) => {
const { tx, ty, scale } = viewRef.current;
const newScale = Math.max(0.2, Math.min(5, scale * factor));
const k = newScale / scale;
setScale(newScale);
setTx(px - k * (px - tx));
setTy(py - k * (py - ty));
}, []);
const handleWheel = useCallback(
(e: React.WheelEvent<SVGSVGElement>) => {
e.preventDefault();
const rect = e.currentTarget.getBoundingClientRect();
zoomAt(
e.clientX - rect.left,
e.clientY - rect.top,
e.deltaY < 0 ? 1.1 : 0.9
);
},
[zoomAt]
);
const handlePointerDown = useCallback(
(e: React.PointerEvent<SVGSVGElement>) => {
if (
e.target === e.currentTarget ||
(e.target as Element).tagName === "svg"
) {
dragRef.current = { startX: e.clientX, startY: e.clientY, tx, ty };
(e.currentTarget as SVGSVGElement).setPointerCapture(e.pointerId);
}
},
[tx, ty]
);
const handlePointerMove = useCallback(
(e: React.PointerEvent<SVGSVGElement>) => {
if (!dragRef.current) return;
setTx(dragRef.current.tx + e.clientX - dragRef.current.startX);
setTy(dragRef.current.ty + e.clientY - dragRef.current.startY);
},
[]
);
const handlePointerUp = useCallback(() => {
dragRef.current = null;
}, []);
const resetView = () => {
setTx(0);
setTy(0);
setScale(1);
};
const toggleFilter = (rel: string) => {
setActiveFilters((prev) => {
const next = new Set(prev);
if (next.has(rel)) {
if (next.size === 1) return prev;
next.delete(rel);
} else {
next.add(rel);
}
return next;
});
};
const toggleType = (type: string) => {
setActiveTypes((prev) => {
const next = new Set(prev);
if (next.has(type)) {
if (next.size === 1) return prev;
next.delete(type);
} else {
next.add(type);
}
return next;
});
};
if (w === 0 || h === 0) return null;
const typeOf = (memory_type: string) =>
memory_type === "procedural" ? "procedural" : "semantic";
const visibleNodeIds = new Set(
data.nodes
.filter((n) => activeTypes.has(typeOf(n.memory_type)))
.map((n) => n.id)
);
const visibleEdges = data.edges.filter(
(e) =>
visibleNodeIds.has(e.source) &&
visibleNodeIds.has(e.target) &&
(activeFilters.has(e.relation) ||
!ALL_RELATIONS.includes(e.relation as RelationType))
);
const relations = ["complements", "contradicts", "supersedes", "default"];
return (
<>
{/* Toolbar */}
<div className="absolute left-3 top-3 z-10 flex gap-1">
<button
type="button"
onClick={() => zoomAt(w / 2, h / 2, 1.25)}
className={GRAPH_BUTTON_CLASS}
aria-label="Zoom in observation graph"
title="Zoom in"
>
<ZoomIn
className="h-3.5 w-3.5"
aria-hidden="true"
/>
</button>
<button
type="button"
onClick={() => zoomAt(w / 2, h / 2, 0.8)}
className={GRAPH_BUTTON_CLASS}
aria-label="Zoom out observation graph"
title="Zoom out"
>
<ZoomOut
className="h-3.5 w-3.5"
aria-hidden="true"
/>
</button>
<button
type="button"
onClick={resetView}
className={GRAPH_BUTTON_CLASS}
aria-label="Reset observation graph view"
title="Reset view"
>
<Maximize2
className="h-3.5 w-3.5"
aria-hidden="true"
/>
</button>
</div>
<div className="absolute right-3 top-3 z-10 flex max-w-[calc(100%-1.5rem)] select-none flex-col gap-1 rounded border border-border bg-background/90 px-2.5 py-2 text-[11px] shadow-sm backdrop-blur-sm">
{(["complements", "contradicts", "supersedes"] as const).map((r) => {
const active = activeFilters.has(r);
return (
<button
key={r}
type="button"
onClick={() => toggleFilter(r)}
aria-pressed={active}
title={`${active ? "Hide" : "Show"} ${r} edges`}
className={`flex items-center gap-1.5 text-left transition-opacity ${
active ? "opacity-100" : "opacity-35"
}`}
>
<span
className="inline-block h-2 w-6 flex-shrink-0 rounded-sm"
style={{ background: relationColor(r) }}
/>
<span className="capitalize text-muted-foreground">
{relationLabel(r)}
</span>
</button>
);
})}
<div className="mt-1 flex flex-col gap-0.5 border-t border-border pt-1">
{(
[
["semantic", "Semantic"],
["procedural", "Procedural"],
] as const
).map(([type, label]) => {
const active = activeTypes.has(type);
return (
<button
key={type}
type="button"
onClick={() => toggleType(type)}
aria-pressed={active}
title={`${
active ? "Hide" : "Show"
} ${label.toLowerCase()} nodes`}
className={`flex items-center gap-1.5 text-left transition-opacity ${
active ? "opacity-100" : "opacity-35"
}`}
>
<span
className="inline-block h-3 w-3 flex-shrink-0 rounded-full"
style={{ background: nodeColor(type) }}
/>
<span className="text-muted-foreground">{label}</span>
</button>
);
})}
</div>
</div>
<svg
ref={svgRef}
width={w}
height={h}
className="absolute inset-0 cursor-grab active:cursor-grabbing"
style={{ userSelect: "none" }}
role="img"
aria-label="Observation relationship graph"
onWheel={handleWheel}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onClick={(e) => {
if (
e.target === e.currentTarget ||
(e.target as Element).tagName === "svg"
) {
onSelect(null);
}
}}
>
<defs>
{relations.map((rel) => (
<marker
key={rel}
id={`arrow-${rel}`}
viewBox="0 0 10 10"
markerWidth="7"
markerHeight="7"
refX="10"
refY="5"
orient="auto"
markerUnits="userSpaceOnUse"
>
<path
d="M0,1.5 L10,5 L0,8.5 z"
fill={relationColor(rel)}
/>
</marker>
))}
</defs>
<g transform={`translate(${tx},${ty}) scale(${scale})`}>
{/* Edges */}
{visibleEdges.map((edge, idx) => {
const s = posMap.get(edge.source);
const t = posMap.get(edge.target);
if (!s || !t) return null;
const sNode = nodeMap.get(edge.source);
const tNode = nodeMap.get(edge.target);
const sr = sNode ? nodeRadius(sNode.degree) : 9;
const tr = tNode ? nodeRadius(tNode.degree) : 9;
// Offset endpoints to the circle edge so arrow doesn't overlap.
const dx = t.x - s.x;
const dy = t.y - s.y;
const dist = Math.max(Math.sqrt(dx * dx + dy * dy), 1);
const ux = dx / dist;
const uy = dy / dist;
const x1 = s.x + ux * sr;
const y1 = s.y + uy * sr;
const x2 = t.x - ux * (tr + 2);
const y2 = t.y - uy * (tr + 2);
const rel =
edge.relation in { complements: 1, contradicts: 1, supersedes: 1 }
? edge.relation
: "default";
const color = relationColor(edge.relation);
const gradId = `edge-grad-${idx}`;
// complements is a mutual (undirected) relation — no single
// arrow direction, so fade both ends symmetrically and drop the
// arrowhead. Directional relations fade only into the arrow.
const symmetric = edge.relation === "complements";
const lineLen = Math.max(dist - sr - (tr + 2), 1);
const fadePct = Math.min(45, (7 / lineLen) * 100);
return (
<g key={idx}>
<linearGradient
id={gradId}
gradientUnits="userSpaceOnUse"
x1={x1}
y1={y1}
x2={x2}
y2={y2}
>
{symmetric ? (
<>
<stop
offset="0%"
stopColor={color}
stopOpacity={0}
/>
<stop
offset={`${fadePct}%`}
stopColor={color}
stopOpacity={0.7}
/>
<stop
offset={`${100 - fadePct}%`}
stopColor={color}
stopOpacity={0.7}
/>
<stop
offset="100%"
stopColor={color}
stopOpacity={0}
/>
</>
) : (
<>
<stop
offset="0%"
stopColor={color}
stopOpacity={0.7}
/>
<stop
offset={`${100 - fadePct}%`}
stopColor={color}
stopOpacity={0.7}
/>
<stop
offset="100%"
stopColor={color}
stopOpacity={0}
/>
</>
)}
</linearGradient>
<line
x1={x1}
y1={y1}
x2={x2}
y2={y2}
stroke={`url(#${gradId})`}
strokeWidth={1.5}
strokeDasharray={
edge.relation === "contradicts" ? "5,3" : undefined
}
markerEnd={symmetric ? undefined : `url(#arrow-${rel})`}
/>
</g>
);
})}
{/* Nodes */}
{data.nodes.map((node) => {
const pos = posMap.get(node.id);
if (!pos) return null;
if (!visibleNodeIds.has(node.id)) return null;
const r = nodeRadius(node.degree);
const isSelected = node.id === selectedId;
const label =
node.summary.length > 28
? node.summary.slice(0, 26) + "…"
: node.summary;
return (
<g
key={node.id}
role="button"
tabIndex={0}
aria-label={`Select observation: ${node.summary}`}
style={{ cursor: "pointer" }}
onClick={(e) => {
e.stopPropagation();
onSelect(node.id === selectedId ? null : node.id);
}}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onSelect(node.id === selectedId ? null : node.id);
}
}}
>
{/* Selection ring — brand accent with a soft halo */}
{isSelected && (
<>
<circle
cx={pos.x}
cy={pos.y}
r={r + 7}
fill="none"
style={{ stroke: "var(--brand)" }}
strokeWidth={2}
opacity={0.3}
/>
<circle
cx={pos.x}
cy={pos.y}
r={r + 4}
fill="none"
style={{ stroke: "var(--brand)" }}
strokeWidth={2.5}
/>
</>
)}
<circle
cx={pos.x}
cy={pos.y}
r={r}
fill={nodeColor(node.memory_type)}
stroke="#fff"
strokeWidth={1.5}
opacity={0.9}
/>
<text
x={pos.x}
y={pos.y + r + 12}
textAnchor="middle"
fontSize={10}
fill="currentColor"
className="select-none fill-foreground/70"
>
{label}
</text>
<title>{node.summary}</title>
</g>
);
})}
</g>
</svg>
</>
);
}
// ---------------------------------------------------------------------------
// Empty / loading / error states
// ---------------------------------------------------------------------------
function EmptyState({ message }: { message: string }) {
return (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
{message}
</div>
);
}
// ---------------------------------------------------------------------------
// Main export
// ---------------------------------------------------------------------------
interface ObservationGraphProps {
data: ObsGraphData | null;
loading: boolean;
error: string | null;
highlightNodeId?: string | null;
}
export function ObservationGraph({
data,
loading,
error,
highlightNodeId,
}: ObservationGraphProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [dims, setDims] = useState({ w: 0, h: 0 });
const [selectedId, setSelectedId] = useState<string | null>(null);
const [isDesktop, setIsDesktop] = useState(false);
const [detailWidth, setDetailWidth] = useState(360);
const resizeRef = useRef<{ startX: number; startW: number } | null>(null);
useEffect(() => {
if (highlightNodeId != null) {
setSelectedId(highlightNodeId);
}
}, [highlightNodeId]);
useEffect(() => {
const mq = window.matchMedia("(min-width: 768px)");
const update = () => setIsDesktop(mq.matches);
update();
mq.addEventListener("change", update);
return () => mq.removeEventListener("change", update);
}, []);
const onResizeDown = useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
resizeRef.current = { startX: e.clientX, startW: detailWidth };
e.currentTarget.setPointerCapture(e.pointerId);
},
[detailWidth]
);
const onResizeMove = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
if (!resizeRef.current) return;
const dx = e.clientX - resizeRef.current.startX;
const max =
typeof window !== "undefined"
? Math.max(640, window.innerWidth * 0.6)
: 640;
setDetailWidth(Math.min(max, Math.max(280, resizeRef.current.startW - dx)));
}, []);
const onResizeUp = useCallback(() => {
resizeRef.current = null;
}, []);
// Measure container.
useEffect(() => {
const el = containerRef.current;
if (!el) return;
const ro = new ResizeObserver(([entry]) => {
setDims({
w: entry.contentRect.width,
h: entry.contentRect.height,
});
});
ro.observe(el);
return () => ro.disconnect();
}, []);
const nodeMap = new Map((data?.nodes ?? []).map((n) => [n.id, n]));
const selectedNode = selectedId ? nodeMap.get(selectedId) ?? null : null;
const showDetail = selectedNode !== null;
return (
<div className="flex h-full min-h-0 w-full flex-col">
{/* Toolbar — no "Observations" title; the parent tab already says that */}
<div className="flex items-center border-b border-border px-4 py-2">
<span className="text-xs text-muted-foreground">
{data
? `${data.nodes.length} nodes · ${data.edges.length} edges`
: loading
? "Loading…"
: ""}
</span>
</div>
{/* Body */}
<div className="relative flex min-h-0 flex-1">
{/* Graph area — always the first child so it never remounts */}
<div
ref={containerRef}
className="relative min-w-0 flex-1 overflow-hidden bg-muted/20"
>
{loading && <EmptyState message="Loading observations…" />}
{!loading && error && <EmptyState message={`Error: ${error}`} />}
{!loading && !error && data && data.nodes.length === 0 && (
<EmptyState message="No observations yet." />
)}
{!loading &&
!error &&
data &&
data.nodes.length > 0 &&
dims.w > 0 && (
<GraphCanvas
data={data}
selectedId={selectedId}
onSelect={setSelectedId}
w={dims.w}
h={dims.h}
/>
)}
</div>
{/* Detail panel — resizable side panel on desktop, overlay on mobile */}
{showDetail && isDesktop && (
<div
role="separator"
aria-orientation="vertical"
onPointerDown={onResizeDown}
onPointerMove={onResizeMove}
onPointerUp={onResizeUp}
className="hover:bg-[var(--brand)]/40 w-1 flex-shrink-0 cursor-col-resize bg-border transition-colors"
/>
)}
{showDetail && (
<div
className={
isDesktop
? "flex-shrink-0 overflow-hidden"
: "absolute inset-0 z-20 overflow-hidden"
}
style={isDesktop ? { width: detailWidth } : undefined}
>
<NodeDetailPanel
node={selectedNode!}
edges={data?.edges ?? []}
nodeMap={nodeMap}
onClose={() => setSelectedId(null)}
onNavigate={(id) => setSelectedId(id)}
/>
</div>
)}
</div>
</div>
);
}
+175
View File
@@ -0,0 +1,175 @@
// Client-side types and force-simulation helpers for the observation graph.
export interface ObsNode {
id: string;
path: string;
summary: string;
memory_type: string;
scope: string;
created_at: string;
degree: number;
}
export interface ObsEdge {
source: string;
target: string;
relation: string;
}
export interface ObsGraphData {
nodes: ObsNode[];
edges: ObsEdge[];
}
export interface NodePos {
id: string;
x: number;
y: number;
vx: number;
vy: number;
}
function stableJitter(id: string, axis: number): number {
let hash = 2166136261 ^ axis;
for (let i = 0; i < id.length; i++) {
hash ^= id.charCodeAt(i);
hash = Math.imul(hash, 16777619);
}
return ((hash >>> 0) / 0xffffffff - 0.5) * 4;
}
export function initPositions(
nodes: ObsNode[],
w: number,
h: number
): NodePos[] {
const count = Math.max(nodes.length, 1);
// Larger initial radius for many nodes so repulsion can spread them cleanly.
const r = Math.min(w, h) * Math.max(0.28, 0.12 * Math.sqrt(count));
return nodes.map((n, i) => {
const angle = (i / count) * 2 * Math.PI;
// Tiny deterministic jitter so perfectly-overlapping nodes diverge without
// making the graph jump to a different layout on every open.
return {
id: n.id,
x: w / 2 + r * Math.cos(angle) + stableJitter(n.id, 0),
y: h / 2 + r * Math.sin(angle) + stableJitter(n.id, 1),
vx: 0,
vy: 0,
};
});
}
/** One physics tick. Returns updated positions and total kinetic energy. */
export function tickSimulation(
positions: NodePos[],
edges: ObsEdge[],
w: number,
h: number
): { positions: NodePos[]; energy: number } {
const REPULSION = 6000;
const SPRING_K = 0.04;
const IDEAL_LENGTH = 180;
const GRAVITY = 0.02;
const DAMPING = 0.78;
const MAX_SPEED = 12;
const cx = w / 2;
const cy = h / 2;
const fx = new Float64Array(positions.length);
const fy = new Float64Array(positions.length);
const posIdx = new Map(positions.map((p, i) => [p.id, i]));
// O(n²) charge repulsion — fine for the expected <200 nodes.
for (let i = 0; i < positions.length; i++) {
for (let j = i + 1; j < positions.length; j++) {
const dx = positions[i].x - positions[j].x;
const dy = positions[i].y - positions[j].y;
const dist = Math.max(Math.sqrt(dx * dx + dy * dy), 1);
const f = REPULSION / (dist * dist);
const ux = dx / dist;
const uy = dy / dist;
fx[i] += f * ux;
fy[i] += f * uy;
fx[j] -= f * ux;
fy[j] -= f * uy;
}
}
// Spring attraction on edges.
for (const edge of edges) {
const si = posIdx.get(edge.source);
const ti = posIdx.get(edge.target);
if (si == null || ti == null) continue;
const dx = positions[ti].x - positions[si].x;
const dy = positions[ti].y - positions[si].y;
const dist = Math.max(Math.sqrt(dx * dx + dy * dy), 1);
const f = SPRING_K * (dist - IDEAL_LENGTH);
const ux = dx / dist;
const uy = dy / dist;
fx[si] += f * ux;
fy[si] += f * uy;
fx[ti] -= f * ux;
fy[ti] -= f * uy;
}
// Gravity towards centre.
for (let i = 0; i < positions.length; i++) {
fx[i] += GRAVITY * (cx - positions[i].x);
fy[i] += GRAVITY * (cy - positions[i].y);
}
// Integrate, clamping per-tick speed so nodes glide instead of darting.
let energy = 0;
const next: NodePos[] = positions.map((p, i) => {
let vx = (p.vx + fx[i]) * DAMPING;
let vy = (p.vy + fy[i]) * DAMPING;
const speed = Math.sqrt(vx * vx + vy * vy);
if (speed > MAX_SPEED) {
vx = (vx / speed) * MAX_SPEED;
vy = (vy / speed) * MAX_SPEED;
}
energy += vx * vx + vy * vy;
return { id: p.id, x: p.x + vx, y: p.y + vy, vx, vy };
});
return { positions: next, energy };
}
// ---------------------------------------------------------------------------
// Visual helpers
// ---------------------------------------------------------------------------
export function relationColor(relation: string): string {
switch (relation) {
case "complements":
return "#10b981"; // emerald
case "contradicts":
return "#f43f5e"; // rose
case "supersedes":
return "#f59e0b"; // amber
default:
return "#94a3b8"; // slate
}
}
export function nodeRadius(degree: number): number {
return Math.max(9, Math.min(22, 9 + degree * 2.5));
}
export function nodeColor(memory_type: string): string {
return memory_type === "procedural" ? "#0ea5e9" : "#6366f1";
}
export function relationLabel(relation: string): string {
switch (relation) {
case "complements":
return "Complements";
case "contradicts":
return "Contradicts";
case "supersedes":
return "Supersedes";
default:
return relation;
}
}
+201
View File
@@ -18,6 +18,7 @@ import { homedir } from "os";
import { join, dirname, relative, resolve, sep } from "path";
import { randomUUID } from "crypto";
import { hasControlChar } from "@/lib/server/workspace";
import type { ObsGraphData, ObsNode, ObsEdge } from "@/lib/observationGraph";
// Re-export so memory API routes can share the workspace cross-origin guard.
export { isCrossOrigin } from "@/lib/server/workspace";
@@ -268,6 +269,22 @@ export interface MemoryFile {
mtime: number;
}
export interface ExecEntry {
id: string;
created_at: string;
agent: string;
session_id: string;
project_id: string;
summary: string;
obs_ids: string[];
path: string;
}
export interface ExecListData {
entries: ExecEntry[];
truncated: boolean;
}
/** Read one memory text file. */
export async function readMemory(relPath: string): Promise<MemoryFile> {
const root = await canonicalDirIfExists();
@@ -319,6 +336,190 @@ export async function writeMemory(
};
}
function extractFm(content: string): string {
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
return match ? match[1] : "";
}
function fmScalar(yaml: string, key: string): string {
const re = new RegExp(`^${key}:\\s*(.+)`, "m");
const m = yaml.match(re);
if (!m) return "";
return m[1]
.trim()
.replace(/^'(.*)'$/, "$1")
.replace(/^"(.*)"$/, "$1");
}
function fmRelated(yaml: string): Array<{ id: string; relation: string }> {
const start = yaml.indexOf("related_observations:");
if (start === -1) return [];
const chunk = yaml.slice(start + "related_observations:".length);
const results: Array<{ id: string; relation: string }> = [];
const itemRe = /^- id:\s*(.+)$/gm;
let m: RegExpExecArray | null;
while ((m = itemRe.exec(chunk)) !== null) {
const itemStart = m.index;
const rest = chunk.slice(itemStart + 1);
const nextBoundary = rest.match(/^(?:- |[a-z_])/m);
const itemText = chunk.slice(
itemStart,
itemStart + 1 + (nextBoundary?.index ?? rest.length)
);
const id = m[1].trim().replace(/^['"]|['"]$/g, "");
const relMatch = itemText.match(/^\s+relation:\s*(.+)$/m);
const relation = relMatch?.[1]?.trim() ?? "";
if (id && relation) results.push({ id, relation });
}
return results;
}
function fmNestedScalar(yaml: string, key: string): string {
const re = new RegExp(`\\b${key}:\\s*(.+)`, "m");
const m = yaml.match(re);
if (!m) return "";
return m[1]
.trim()
.replace(/^'(.*)'$/, "$1")
.replace(/^"(.*)"$/, "$1");
}
export async function listObservations(): Promise<ObsGraphData> {
const maybeRoot = await canonicalDirIfExists();
if (!maybeRoot) return { nodes: [], edges: [] };
const root = maybeRoot;
const obsDir = join(root, "observations");
try {
await fs.access(obsDir);
} catch {
return { nodes: [], edges: [] };
}
const nodeMap = new Map<string, ObsNode>();
const rawEdges: ObsEdge[] = [];
async function walk(dir: string): Promise<void> {
let dirents: import("fs").Dirent[];
try {
dirents = await fs.readdir(dir, { withFileTypes: true });
} catch {
return;
}
dirents.sort((a, b) => a.name.localeCompare(b.name));
for (const ent of dirents) {
if (isHiddenEntry(ent.name)) continue;
const abs = join(dir, ent.name);
if (ent.isDirectory()) {
await walk(abs);
} else if (ent.isFile() && ent.name.endsWith(".md")) {
const content = await fs.readFile(abs, "utf-8").catch(() => null);
if (!content) continue;
const yaml = extractFm(content);
const id = fmScalar(yaml, "id") || ent.name.replace(/\.md$/, "");
const summary = fmScalar(yaml, "summary") || id;
const memory_type = fmScalar(yaml, "memory_type") || "semantic";
const scope = fmScalar(yaml, "scope") || "global";
const created_at = fmScalar(yaml, "created_at") || "";
const related = fmRelated(yaml);
const path = relative(root, abs).split(sep).join("/");
nodeMap.set(id, {
id,
path,
summary,
memory_type,
scope,
created_at,
degree: 0,
});
for (const rel of related) {
rawEdges.push({ source: id, target: rel.id, relation: rel.relation });
}
}
}
}
await walk(obsDir);
const edges = rawEdges.filter(
(e) => nodeMap.has(e.source) && nodeMap.has(e.target)
);
for (const e of edges) {
nodeMap.get(e.source)!.degree++;
nodeMap.get(e.target)!.degree++;
}
return { nodes: Array.from(nodeMap.values()), edges };
}
export async function listExecutions(): Promise<ExecListData> {
const maybeRoot = await canonicalDirIfExists();
if (!maybeRoot) return { entries: [], truncated: false };
const root = maybeRoot;
const execDir = join(root, "executions");
try {
await fs.access(execDir);
} catch {
return { entries: [], truncated: false };
}
const entries: ExecEntry[] = [];
let truncated = false;
async function walk(dir: string): Promise<void> {
if (entries.length >= MAX_ENTRIES) {
truncated = true;
return;
}
let dirents: import("fs").Dirent[];
try {
dirents = await fs.readdir(dir, { withFileTypes: true });
} catch {
return;
}
dirents.sort((a, b) => a.name.localeCompare(b.name));
for (const ent of dirents) {
if (isHiddenEntry(ent.name)) continue;
const abs = join(dir, ent.name);
if (ent.isDirectory()) {
await walk(abs);
} else if (ent.isFile() && ent.name.endsWith(".md")) {
const content = await fs.readFile(abs, "utf-8").catch(() => null);
if (!content) continue;
const yaml = extractFm(content);
const id = fmScalar(yaml, "id") || ent.name.replace(/\.md$/, "");
const created_at = fmScalar(yaml, "created_at") || "";
const project_id = fmScalar(yaml, "project_id") || "";
const agent = fmNestedScalar(yaml, "agent");
const session_id = fmNestedScalar(yaml, "session_id");
const body = content
.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, "")
.trim();
const summaryMatch = body.match(
/^##\s+Summary\s*\r?\n([\s\S]*?)(?=\n##\s|\s*$)/m
);
const summary = summaryMatch
? summaryMatch[1].trim()
: body.slice(0, 500);
const obs_ids = [...new Set(body.match(/\bO-[0-9a-f]{16}\b/g) ?? [])];
entries.push({
id,
created_at,
agent,
session_id,
project_id,
summary,
obs_ids,
path: relative(root, abs).split(sep).join("/"),
});
}
}
}
await walk(execDir);
entries.sort((a, b) => b.created_at.localeCompare(a.created_at));
return { entries, truncated };
}
/** Permanently delete a memory file. */
export async function deleteMemory(relPath: string): Promise<void> {
const root = await canonicalDirIfExists();