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:
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+218
-917
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user