From e6dacbe809eb38e3e47166eeb3220158d3d35425 Mon Sep 17 00:00:00 2001 From: saravanakumardb1 Date: Fri, 10 Apr 2026 01:46:51 -0700 Subject: [PATCH] =?UTF-8?q?feat(palace):=20web=20UI=20=E2=80=94=20palace?= =?UTF-8?q?=20client,=204=20components,=20palace=20page,=20sidebar=20nav,?= =?UTF-8?q?=206=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/src/app/(app)/palace/page.tsx | 89 ++++++++++ web/src/components/KnowledgeGraphView.tsx | 141 ++++++++++++++++ web/src/components/MemoryTimeline.tsx | 73 +++++++++ web/src/components/PalacePanel.test.tsx | 43 +++++ web/src/components/PalacePanel.tsx | 117 +++++++++++++ web/src/components/PalaceStats.test.tsx | 38 +++++ web/src/components/PalaceStats.tsx | 58 +++++++ web/src/components/Sidebar.tsx | 3 +- web/src/lib/palace-client.test.ts | 131 +++++++++++++++ web/src/lib/palace-client.ts | 190 ++++++++++++++++++++++ 10 files changed, 882 insertions(+), 1 deletion(-) create mode 100644 web/src/app/(app)/palace/page.tsx create mode 100644 web/src/components/KnowledgeGraphView.tsx create mode 100644 web/src/components/MemoryTimeline.tsx create mode 100644 web/src/components/PalacePanel.test.tsx create mode 100644 web/src/components/PalacePanel.tsx create mode 100644 web/src/components/PalaceStats.test.tsx create mode 100644 web/src/components/PalaceStats.tsx create mode 100644 web/src/lib/palace-client.test.ts create mode 100644 web/src/lib/palace-client.ts diff --git a/web/src/app/(app)/palace/page.tsx b/web/src/app/(app)/palace/page.tsx new file mode 100644 index 0000000..f6b252b --- /dev/null +++ b/web/src/app/(app)/palace/page.tsx @@ -0,0 +1,89 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { AppShell } from "@/components/AppShell"; +import { PalacePanel } from "@/components/PalacePanel"; +import { PalaceStats } from "@/components/PalaceStats"; +import { KnowledgeGraphView } from "@/components/KnowledgeGraphView"; +import { MemoryTimeline } from "@/components/MemoryTimeline"; +import { listWings, type PalaceWing } from "@/lib/palace-client"; + +type Tab = "memories" | "timeline" | "graph" | "stats"; + +export default function PalacePage() { + const [wings, setWings] = useState([]); + const [selectedWing, setSelectedWing] = useState(); + const [tab, setTab] = useState("memories"); + const [loadingWings, setLoadingWings] = useState(true); + + useEffect(() => { + void (async () => { + try { + const result = await listWings(); + setWings(result); + if (result.length > 0) setSelectedWing(result[0].id); + } catch { + // Handled gracefully + } finally { + setLoadingWings(false); + } + })(); + }, []); + + const tabs: { key: Tab; label: string }[] = [ + { key: "memories", label: "Memories" }, + { key: "timeline", label: "Timeline" }, + { key: "graph", label: "Knowledge Graph" }, + { key: "stats", label: "Stats" }, + ]; + + return ( + +
+
+

Memory Palace

+ + {!loadingWings && wings.length > 0 && ( + + )} +
+ + + + {tab === "memories" && } + {tab === "timeline" && selectedWing && } + {tab === "timeline" && !selectedWing && ( +
+ Select a wing to view the memory timeline. +
+ )} + {tab === "graph" && } + {tab === "stats" && } +
+
+ ); +} diff --git a/web/src/components/KnowledgeGraphView.tsx b/web/src/components/KnowledgeGraphView.tsx new file mode 100644 index 0000000..d695c8b --- /dev/null +++ b/web/src/components/KnowledgeGraphView.tsx @@ -0,0 +1,141 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { queryEntity, getEntityTimeline, getKGContradictions, type PalaceKGTriple } from "@/lib/palace-client"; + +interface KnowledgeGraphViewProps { + wingId?: string; +} + +export function KnowledgeGraphView({ wingId }: KnowledgeGraphViewProps) { + const [entity, setEntity] = useState(""); + const [triples, setTriples] = useState([]); + const [contradictions, setContradictions] = useState>([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const loadContradictions = useCallback(async () => { + try { + const result = await getKGContradictions(wingId); + setContradictions(result); + } catch { + // Ignore — contradictions are supplementary + } + }, [wingId]); + + useEffect(() => { + void loadContradictions(); + }, [loadContradictions]); + + const handleQuery = async () => { + if (!entity.trim()) return; + try { + setLoading(true); + setError(null); + const result = await queryEntity(entity); + setTriples(result); + } catch { + setError("Query failed"); + } finally { + setLoading(false); + } + }; + + const handleTimeline = async () => { + if (!entity.trim()) return; + try { + setLoading(true); + setError(null); + const result = await getEntityTimeline(entity); + setTriples(result); + } catch { + setError("Timeline query failed"); + } finally { + setLoading(false); + } + }; + + return ( +
+
Knowledge Graph
+ +
+ setEntity(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleQuery()} + placeholder="Query entity (e.g. React, Fastify)..." + aria-label="Query knowledge graph entity" + className="input" + style={{ flex: 1 }} + /> + + +
+ + {error &&
{error}
} + {loading &&
Loading...
} + + {!loading && triples.length === 0 && entity && ( +
+ No triples found for “{entity}” +
+ )} + +
+ {triples.map((t) => ( +
+ {t.subject} + {t.predicate} + {t.object} + + {Math.round(t.confidence * 100)}% + {t.validTo && " (invalidated)"} + +
+ ))} +
+ + {contradictions.length > 0 && ( +
+
+ Contradictions ({contradictions.length}) +
+ {contradictions.map((c, i) => ( +
+
{c.a.subject} {c.a.predicate} → {c.a.object}
+
+ vs → {c.b.object} +
+
+ ))} +
+ )} +
+ ); +} diff --git a/web/src/components/MemoryTimeline.tsx b/web/src/components/MemoryTimeline.tsx new file mode 100644 index 0000000..0843e9e --- /dev/null +++ b/web/src/components/MemoryTimeline.tsx @@ -0,0 +1,73 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { listMemories, type PalaceMemory } from "@/lib/palace-client"; + +interface MemoryTimelineProps { + wingId: string; +} + +export function MemoryTimeline({ wingId }: MemoryTimelineProps) { + const [memories, setMemories] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + void (async () => { + try { + setLoading(true); + const result = await listMemories({ wingId, limit: 50 }); + setMemories(result); + } catch { + setError("Failed to load memory timeline"); + } finally { + setLoading(false); + } + })(); + }, [wingId]); + + if (loading) return
Loading timeline...
; + if (error) return
{error}
; + if (memories.length === 0) { + return ( +
+ No memories yet. Save notes to start building your memory palace. +
+ ); + } + + // Group by date + const grouped: Record = {}; + for (const mem of memories) { + const day = new Date(mem.createdAt).toLocaleDateString("en-US", { + weekday: "short", month: "short", day: "numeric", + }); + if (!grouped[day]) grouped[day] = []; + grouped[day].push(mem); + } + + return ( +
+ {Object.entries(grouped).map(([day, mems]) => ( +
+
+ {day} +
+
+ {mems.map((mem) => ( +
+
+ {mem.hall} + + {new Date(mem.createdAt).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit" })} + +
+
{mem.content}
+
+ ))} +
+
+ ))} +
+ ); +} diff --git a/web/src/components/PalacePanel.test.tsx b/web/src/components/PalacePanel.test.tsx new file mode 100644 index 0000000..158833b --- /dev/null +++ b/web/src/components/PalacePanel.test.tsx @@ -0,0 +1,43 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import { PalacePanel } from "./PalacePanel"; + +const mockListMemories = vi.fn(); +const mockSearchPalace = vi.fn(); + +vi.mock("@/lib/palace-client", () => ({ + listMemories: (...args: unknown[]) => mockListMemories(...args), + searchPalace: (...args: unknown[]) => mockSearchPalace(...args), +})); + +beforeEach(() => { + mockListMemories.mockReset(); + mockSearchPalace.mockReset(); +}); + +describe("PalacePanel", () => { + it("renders memories after loading", async () => { + mockListMemories.mockResolvedValue([ + { id: "m1", hall: "decisions", content: "Use JWT for auth", createdAt: "2026-01-01T00:00:00Z" }, + ]); + render(); + await waitFor(() => expect(screen.getByText("Use JWT for auth")).toBeInTheDocument()); + expect(screen.getByText("decisions")).toBeInTheDocument(); + }); + + it("renders empty state", async () => { + mockListMemories.mockResolvedValue([]); + render(); + await waitFor(() => + expect(screen.getByText(/No memories found/)).toBeInTheDocument(), + ); + }); + + it("has search input", async () => { + mockListMemories.mockResolvedValue([]); + render(); + await waitFor(() => { + expect(screen.getByLabelText("Search palace memories")).toBeInTheDocument(); + }); + }); +}); diff --git a/web/src/components/PalacePanel.tsx b/web/src/components/PalacePanel.tsx new file mode 100644 index 0000000..4c9596b --- /dev/null +++ b/web/src/components/PalacePanel.tsx @@ -0,0 +1,117 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { searchPalace, listMemories, type PalaceMemory } from "@/lib/palace-client"; + +interface PalacePanelProps { + wingId?: string; +} + +export function PalacePanel({ wingId }: PalacePanelProps) { + const [query, setQuery] = useState(""); + const [memories, setMemories] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const loadRecent = useCallback(async () => { + try { + setLoading(true); + setError(null); + const result = await listMemories({ wingId, limit: 20 }); + setMemories(result); + } catch { + setError("Failed to load memories"); + } finally { + setLoading(false); + } + }, [wingId]); + + useEffect(() => { + void loadRecent(); + }, [loadRecent]); + + const handleSearch = async () => { + if (!query.trim()) { + void loadRecent(); + return; + } + try { + setLoading(true); + setError(null); + const results = await searchPalace(query, wingId); + setMemories(results); + } catch { + setError("Search failed"); + } finally { + setLoading(false); + } + }; + + const hallColor: Record = { + decisions: "var(--nl-status-info)", + events: "var(--nl-status-success)", + discoveries: "var(--nl-status-warning)", + preferences: "var(--nl-accent)", + advice: "var(--nl-text-secondary)", + insights: "var(--nl-status-info)", + }; + + return ( +
+
Palace Memory
+ +
+ setQuery(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleSearch()} + placeholder="Search memories..." + aria-label="Search palace memories" + className="input" + style={{ flex: 1 }} + /> + +
+ + {error &&
{error}
} + {loading &&
Loading...
} + + {!loading && memories.length === 0 && ( +
+ No memories found. Memories are automatically extracted from your notes. +
+ )} + +
+ {memories.map((mem) => ( +
+
+ + {mem.hall} + + + {new Date(mem.createdAt).toLocaleDateString()} + +
+
{mem.content}
+
+ ))} +
+
+ ); +} diff --git a/web/src/components/PalaceStats.test.tsx b/web/src/components/PalaceStats.test.tsx new file mode 100644 index 0000000..53b6b14 --- /dev/null +++ b/web/src/components/PalaceStats.test.tsx @@ -0,0 +1,38 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import { PalaceStats } from "./PalaceStats"; + +const mockGetPalaceStats = vi.fn(); +vi.mock("@/lib/palace-client", () => ({ + getPalaceStats: (...args: unknown[]) => mockGetPalaceStats(...args), +})); + +beforeEach(() => { + mockGetPalaceStats.mockReset(); +}); + +describe("PalaceStats", () => { + it("renders stats after loading", async () => { + mockGetPalaceStats.mockResolvedValue({ + wings: 3, rooms: 7, memories: 42, kgTriples: 15, tunnels: 0, diaries: 0, + }); + render(); + await waitFor(() => expect(screen.getByText("42")).toBeInTheDocument()); + expect(screen.getByText("Wings")).toBeInTheDocument(); + expect(screen.getByText("3")).toBeInTheDocument(); + expect(screen.getByText("KG Triples")).toBeInTheDocument(); + expect(screen.getByText("15")).toBeInTheDocument(); + }); + + it("renders error when fetch fails", async () => { + mockGetPalaceStats.mockRejectedValue(new Error("fail")); + render(); + await waitFor(() => expect(screen.getByText("Failed to load palace stats")).toBeInTheDocument()); + }); + + it("renders loading state initially", () => { + mockGetPalaceStats.mockReturnValue(new Promise(() => {})); // never resolves + render(); + expect(screen.getByText("Loading stats...")).toBeInTheDocument(); + }); +}); diff --git a/web/src/components/PalaceStats.tsx b/web/src/components/PalaceStats.tsx new file mode 100644 index 0000000..65eb329 --- /dev/null +++ b/web/src/components/PalaceStats.tsx @@ -0,0 +1,58 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { getPalaceStats, type PalaceStats as PalaceStatsData } from "@/lib/palace-client"; + +export function PalaceStats() { + const [stats, setStats] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + void (async () => { + try { + const data = await getPalaceStats(); + setStats(data); + } catch { + setError("Failed to load palace stats"); + } finally { + setLoading(false); + } + })(); + }, []); + + if (loading) return
Loading stats...
; + if (error) return
{error}
; + if (!stats) return null; + + const items = [ + { label: "Wings", value: stats.wings }, + { label: "Rooms", value: stats.rooms }, + { label: "Memories", value: stats.memories }, + { label: "KG Triples", value: stats.kgTriples }, + ]; + + return ( +
+
+ Memory Palace +
+
+ {items.map(({ label, value }) => ( +
+
{value}
+
{label}
+
+ ))} +
+
+ ); +} diff --git a/web/src/components/Sidebar.tsx b/web/src/components/Sidebar.tsx index ccb66d5..e6b6d15 100644 --- a/web/src/components/Sidebar.tsx +++ b/web/src/components/Sidebar.tsx @@ -2,7 +2,7 @@ import Link from "next/link"; import { usePathname } from "next/navigation"; -import { House, Search, Settings, Sparkles, FolderKanban, ShieldCheck, MessageCircle } from "lucide-react"; +import { House, Search, Settings, Sparkles, FolderKanban, ShieldCheck, MessageCircle, Brain } from "lucide-react"; import { PRODUCT_NAME } from "@/lib/product-config"; import { isFeatureEnabled } from "@/lib/feature-flags"; @@ -13,6 +13,7 @@ const navItems: { href: string; label: string; icon: typeof House; flag?: string { href: "/prompts", label: "Prompts", icon: Sparkles }, { href: "/search", label: "Search", icon: Search }, { href: "/chat", label: "Workspace chat", icon: MessageCircle }, + { href: "/palace", label: "Palace", icon: Brain }, { href: "/settings", label: "Settings", icon: Settings }, ]; diff --git a/web/src/lib/palace-client.test.ts b/web/src/lib/palace-client.test.ts new file mode 100644 index 0000000..5cbfa5f --- /dev/null +++ b/web/src/lib/palace-client.test.ts @@ -0,0 +1,131 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const mockFetch = vi.fn(); +vi.mock("@/lib/api-helpers", () => ({ + createNotesApiClient: () => ({ fetch: mockFetch }), +})); + +import { + searchPalace, + listWings, + getWingSummary, + deleteWing, + listRooms, + storeMemory, + listMemories, + deleteMemory, + queryEntity, + getEntityTimeline, + getKGContradictions, + getWakeUpContext, + getPalaceStats, + backfillEmbeddings, + pruneMemories, + getPalaceHealth, +} from "./palace-client"; + +beforeEach(() => { + mockFetch.mockReset(); +}); + +describe("palace-client", () => { + it("searchPalace calls /palace/search with query params", async () => { + mockFetch.mockResolvedValue([{ id: "m1", content: "JWT" }]); + const result = await searchPalace("JWT", "wing-1", 5); + expect(mockFetch).toHaveBeenCalledWith("/palace/search?q=JWT&limit=5&wingId=wing-1"); + expect(result).toHaveLength(1); + }); + + it("listWings calls /palace/wings", async () => { + mockFetch.mockResolvedValue([]); + await listWings(); + expect(mockFetch).toHaveBeenCalledWith("/palace/wings"); + }); + + it("getWingSummary calls /palace/wings/:wingId", async () => { + mockFetch.mockResolvedValue({ wing: { id: "w1" }, rooms: [], totalMemories: 0 }); + await getWingSummary("w1"); + expect(mockFetch).toHaveBeenCalledWith("/palace/wings/w1"); + }); + + it("deleteWing calls DELETE /palace/wings/:wingId", async () => { + mockFetch.mockResolvedValue(undefined); + await deleteWing("w1"); + expect(mockFetch).toHaveBeenCalledWith("/palace/wings/w1", { method: "DELETE" }); + }); + + it("listRooms calls /palace/wings/:wingId/rooms", async () => { + mockFetch.mockResolvedValue([]); + await listRooms("w1"); + expect(mockFetch).toHaveBeenCalledWith("/palace/wings/w1/rooms"); + }); + + it("storeMemory calls POST /palace/memories", async () => { + mockFetch.mockResolvedValue({ stored: true }); + const result = await storeMemory({ + wingId: "w1", roomId: "r1", hall: "decisions", content: "test", + }); + expect(result.stored).toBe(true); + expect(mockFetch).toHaveBeenCalledWith("/palace/memories", expect.objectContaining({ method: "POST" })); + }); + + it("listMemories builds query string correctly", async () => { + mockFetch.mockResolvedValue([]); + await listMemories({ wingId: "w1", hall: "decisions", limit: 5 }); + expect(mockFetch).toHaveBeenCalledWith("/palace/memories?wingId=w1&hall=decisions&limit=5"); + }); + + it("deleteMemory calls DELETE /palace/memories/:id", async () => { + mockFetch.mockResolvedValue(undefined); + await deleteMemory("m1"); + expect(mockFetch).toHaveBeenCalledWith("/palace/memories/m1", { method: "DELETE" }); + }); + + it("queryEntity calls /palace/kg/entity/:entity", async () => { + mockFetch.mockResolvedValue([]); + await queryEntity("React"); + expect(mockFetch).toHaveBeenCalledWith("/palace/kg/entity/React"); + }); + + it("getEntityTimeline calls /palace/kg/timeline/:entity", async () => { + mockFetch.mockResolvedValue([]); + await getEntityTimeline("React"); + expect(mockFetch).toHaveBeenCalledWith("/palace/kg/timeline/React"); + }); + + it("getKGContradictions with wingId", async () => { + mockFetch.mockResolvedValue([]); + await getKGContradictions("w1"); + expect(mockFetch).toHaveBeenCalledWith("/palace/kg/contradictions?wingId=w1"); + }); + + it("getWakeUpContext with task", async () => { + mockFetch.mockResolvedValue({ text: "context", wingName: "Work" }); + await getWakeUpContext("w1", "auth migration"); + expect(mockFetch).toHaveBeenCalledWith("/palace/wake-up/w1?task=auth%20migration"); + }); + + it("getPalaceStats calls /palace/stats", async () => { + mockFetch.mockResolvedValue({ wings: 1, rooms: 2, memories: 5, kgTriples: 3, tunnels: 0, diaries: 0 }); + const stats = await getPalaceStats(); + expect(stats.memories).toBe(5); + }); + + it("backfillEmbeddings calls POST /palace/backfill-embeddings", async () => { + mockFetch.mockResolvedValue({ backfilled: 3 }); + const result = await backfillEmbeddings(); + expect(result.backfilled).toBe(3); + }); + + it("pruneMemories calls POST /palace/prune", async () => { + mockFetch.mockResolvedValue({ pruned: 2 }); + const result = await pruneMemories({ olderThanDays: 180, minRelevance: 0.1 }); + expect(result.pruned).toBe(2); + }); + + it("getPalaceHealth calls /palace/health", async () => { + mockFetch.mockResolvedValue({ cosmos: true, llm: true }); + const health = await getPalaceHealth(); + expect(health.cosmos).toBe(true); + }); +}); diff --git a/web/src/lib/palace-client.ts b/web/src/lib/palace-client.ts new file mode 100644 index 0000000..5efe245 --- /dev/null +++ b/web/src/lib/palace-client.ts @@ -0,0 +1,190 @@ +"use client"; + +import { createNotesApiClient } from "@/lib/api-helpers"; + +// ── Types ───────────────────────────────────────── + +export interface PalaceWing { + id: string; + name: string; + sourceWorkspaceId: string; + memoryCount: number; + techStack?: string; + createdAt: string; + updatedAt: string; +} + +export interface PalaceRoom { + id: string; + wingId: string; + name: string; + description?: string; + memoryCount: number; +} + +export interface PalaceMemory { + id: string; + wingId: string; + roomId: string; + hall: string; + content: string; + relevance: number; + sourceNoteId?: string; + createdAt: string; + updatedAt: string; +} + +export interface PalaceKGTriple { + id: string; + wingId: string; + subject: string; + predicate: string; + object: string; + confidence: number; + validFrom: string; + validTo?: string; +} + +export interface WingSummary { + wing: PalaceWing; + rooms: PalaceRoom[]; + totalMemories: number; +} + +export interface WakeUpContext { + text: string; + wingId: string; + wingName: string; + layers: Array<{ name: string; content: string }>; + totalChars: number; + truncated: boolean; +} + +export interface PalaceStats { + wings: number; + rooms: number; + memories: number; + kgTriples: number; + tunnels: number; + diaries: number; +} + +// ── API Client Functions ────────────────────────── + +export async function searchPalace( + query: string, + wingId?: string, + limit = 10, +): Promise { + const api = createNotesApiClient(); + const params = new URLSearchParams({ q: query, limit: String(limit) }); + if (wingId) params.set("wingId", wingId); + return api.fetch(`/palace/search?${params.toString()}`); +} + +export async function listWings(): Promise { + const api = createNotesApiClient(); + return api.fetch("/palace/wings"); +} + +export async function getWingSummary(wingId: string): Promise { + const api = createNotesApiClient(); + return api.fetch(`/palace/wings/${encodeURIComponent(wingId)}`); +} + +export async function deleteWing(wingId: string): Promise { + const api = createNotesApiClient(); + await api.fetch(`/palace/wings/${encodeURIComponent(wingId)}`, { method: "DELETE" }); +} + +export async function listRooms(wingId: string): Promise { + const api = createNotesApiClient(); + return api.fetch(`/palace/wings/${encodeURIComponent(wingId)}/rooms`); +} + +export async function storeMemory(input: { + wingId: string; + roomId: string; + hall: string; + content: string; + sourceNoteId?: string; +}): Promise<{ stored: boolean; reason?: string; memory?: PalaceMemory }> { + const api = createNotesApiClient(); + return api.fetch(`/palace/memories`, { + method: "POST", + body: JSON.stringify(input), + }); +} + +export async function listMemories(opts?: { + wingId?: string; + roomId?: string; + hall?: string; + limit?: number; +}): Promise { + const api = createNotesApiClient(); + const params = new URLSearchParams(); + if (opts?.wingId) params.set("wingId", opts.wingId); + if (opts?.roomId) params.set("roomId", opts.roomId); + if (opts?.hall) params.set("hall", opts.hall); + if (opts?.limit) params.set("limit", String(opts.limit)); + return api.fetch(`/palace/memories?${params.toString()}`); +} + +export async function deleteMemory(id: string): Promise { + const api = createNotesApiClient(); + await api.fetch(`/palace/memories/${encodeURIComponent(id)}`, { method: "DELETE" }); +} + +export async function queryEntity(entity: string): Promise { + const api = createNotesApiClient(); + return api.fetch(`/palace/kg/entity/${encodeURIComponent(entity)}`); +} + +export async function getEntityTimeline(entity: string): Promise { + const api = createNotesApiClient(); + return api.fetch(`/palace/kg/timeline/${encodeURIComponent(entity)}`); +} + +export async function getKGContradictions(wingId?: string): Promise< + Array<{ a: PalaceKGTriple; b: PalaceKGTriple }> +> { + const api = createNotesApiClient(); + const params = wingId ? `?wingId=${encodeURIComponent(wingId)}` : ""; + return api.fetch(`/palace/kg/contradictions${params}`); +} + +export async function getWakeUpContext( + wingId: string, + task?: string, +): Promise { + const api = createNotesApiClient(); + const params = task ? `?task=${encodeURIComponent(task)}` : ""; + return api.fetch(`/palace/wake-up/${encodeURIComponent(wingId)}${params}`); +} + +export async function getPalaceStats(): Promise { + const api = createNotesApiClient(); + return api.fetch("/palace/stats"); +} + +export async function backfillEmbeddings(): Promise<{ backfilled: number }> { + const api = createNotesApiClient(); + return api.fetch("/palace/backfill-embeddings", { method: "POST" }); +} + +export async function pruneMemories(opts?: { + olderThanDays?: number; + minRelevance?: number; +}): Promise<{ pruned: number }> { + const api = createNotesApiClient(); + const params = new URLSearchParams(); + if (opts?.olderThanDays) params.set("olderThanDays", String(opts.olderThanDays)); + if (opts?.minRelevance) params.set("minRelevance", String(opts.minRelevance)); + return api.fetch(`/palace/prune?${params.toString()}`, { method: "POST" }); +} + +export async function getPalaceHealth(): Promise<{ cosmos: boolean; llm: boolean }> { + const api = createNotesApiClient(); + return api.fetch("/palace/health"); +}