feat(palace): web UI — palace client, 4 components, palace page, sidebar nav, 6 tests

This commit is contained in:
saravanakumardb1 2026-04-10 01:46:51 -07:00
parent 1bf9896ea9
commit e6dacbe809
10 changed files with 882 additions and 1 deletions

View File

@ -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<PalaceWing[]>([]);
const [selectedWing, setSelectedWing] = useState<string | undefined>();
const [tab, setTab] = useState<Tab>("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 (
<AppShell title="Memory Palace" description="Browse your AI-extracted memories, knowledge graph, and context">
<div style={{ display: "grid", gap: "var(--nl-space-5)" }}>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<h1 style={{ fontSize: "var(--nl-fs-2xl)", fontWeight: 700 }}>Memory Palace</h1>
{!loadingWings && wings.length > 0 && (
<select
value={selectedWing ?? ""}
onChange={(e) => setSelectedWing(e.target.value || undefined)}
className="input"
style={{ maxWidth: 240 }}
aria-label="Select wing"
>
<option value="">All wings</option>
{wings.map((w) => (
<option key={w.id} value={w.id}>
{w.name} ({w.memoryCount})
</option>
))}
</select>
)}
</div>
<nav style={{ display: "flex", gap: "var(--nl-space-2)" }} aria-label="Palace tabs">
{tabs.map((t) => (
<button
key={t.key}
onClick={() => setTab(t.key)}
className={tab === t.key ? "btn btn-primary" : "btn"}
aria-current={tab === t.key ? "page" : undefined}
>
{t.label}
</button>
))}
</nav>
{tab === "memories" && <PalacePanel wingId={selectedWing} />}
{tab === "timeline" && selectedWing && <MemoryTimeline wingId={selectedWing} />}
{tab === "timeline" && !selectedWing && (
<div style={{ color: "var(--nl-text-secondary)", textAlign: "center", padding: "var(--nl-space-4)" }}>
Select a wing to view the memory timeline.
</div>
)}
{tab === "graph" && <KnowledgeGraphView wingId={selectedWing} />}
{tab === "stats" && <PalaceStats />}
</div>
</AppShell>
);
}

View File

@ -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<PalaceKGTriple[]>([]);
const [contradictions, setContradictions] = useState<Array<{ a: PalaceKGTriple; b: PalaceKGTriple }>>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(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 (
<section className="surface-card" style={{ padding: "var(--nl-space-5)", display: "grid", gap: "var(--nl-space-3)" }}>
<div style={{ fontWeight: 700, fontSize: "var(--nl-text-lg)" }}>Knowledge Graph</div>
<div style={{ display: "flex", gap: "var(--nl-space-2)" }}>
<input
type="text"
value={entity}
onChange={(e) => 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 }}
/>
<button onClick={handleQuery} className="btn btn-primary" aria-label="Query entity">
Query
</button>
<button onClick={handleTimeline} className="btn" aria-label="Show timeline">
Timeline
</button>
</div>
{error && <div style={{ color: "var(--nl-status-error)" }}>{error}</div>}
{loading && <div style={{ color: "var(--nl-text-secondary)" }}>Loading...</div>}
{!loading && triples.length === 0 && entity && (
<div style={{ color: "var(--nl-text-secondary)", textAlign: "center", padding: "var(--nl-space-4)" }}>
No triples found for &ldquo;{entity}&rdquo;
</div>
)}
<div style={{ display: "grid", gap: "var(--nl-space-2)" }}>
{triples.map((t) => (
<div
key={t.id}
style={{
padding: "var(--nl-space-3)",
borderRadius: "var(--nl-radius-md)",
border: "1px solid var(--nl-border-subtle)",
display: "flex",
alignItems: "center",
gap: "var(--nl-space-2)",
opacity: t.validTo ? 0.5 : 1,
}}
>
<span style={{ fontWeight: 600 }}>{t.subject}</span>
<span style={{ color: "var(--nl-accent)" }}>{t.predicate}</span>
<span style={{ fontWeight: 600 }}>{t.object}</span>
<span style={{ marginLeft: "auto", color: "var(--nl-text-secondary)", fontSize: "0.75rem" }}>
{Math.round(t.confidence * 100)}%
{t.validTo && " (invalidated)"}
</span>
</div>
))}
</div>
{contradictions.length > 0 && (
<div style={{ marginTop: "var(--nl-space-3)" }}>
<div style={{ fontWeight: 600, color: "var(--nl-status-warning)", marginBottom: "var(--nl-space-2)" }}>
Contradictions ({contradictions.length})
</div>
{contradictions.map((c, i) => (
<div
key={i}
style={{
padding: "var(--nl-space-2)",
border: "1px solid var(--nl-status-warning)",
borderRadius: "var(--nl-radius-md)",
marginBottom: "var(--nl-space-2)",
fontSize: "0.875rem",
}}
>
<div>{c.a.subject} {c.a.predicate} <strong>{c.a.object}</strong></div>
<div style={{ color: "var(--nl-status-warning)" }}>
vs <strong>{c.b.object}</strong>
</div>
</div>
))}
</div>
)}
</section>
);
}

View File

@ -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<PalaceMemory[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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 <div style={{ color: "var(--nl-text-secondary)" }}>Loading timeline...</div>;
if (error) return <div style={{ color: "var(--nl-status-error)" }}>{error}</div>;
if (memories.length === 0) {
return (
<div style={{ color: "var(--nl-text-secondary)", textAlign: "center", padding: "var(--nl-space-4)" }}>
No memories yet. Save notes to start building your memory palace.
</div>
);
}
// Group by date
const grouped: Record<string, PalaceMemory[]> = {};
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 (
<section style={{ display: "grid", gap: "var(--nl-space-4)" }}>
{Object.entries(grouped).map(([day, mems]) => (
<div key={day}>
<div style={{ fontWeight: 600, color: "var(--nl-text-secondary)", fontSize: "0.875rem", marginBottom: "var(--nl-space-2)" }}>
{day}
</div>
<div style={{ display: "grid", gap: "var(--nl-space-2)", borderLeft: "2px solid var(--nl-border-subtle)", paddingLeft: "var(--nl-space-3)" }}>
{mems.map((mem) => (
<div key={mem.id} style={{ display: "grid", gap: "var(--nl-space-1)" }}>
<div style={{ display: "flex", gap: "var(--nl-space-2)", alignItems: "center" }}>
<span className="badge" style={{ fontSize: "0.7rem" }}>{mem.hall}</span>
<span style={{ color: "var(--nl-text-secondary)", fontSize: "0.75rem" }}>
{new Date(mem.createdAt).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit" })}
</span>
</div>
<div style={{ fontSize: "0.875rem" }}>{mem.content}</div>
</div>
))}
</div>
</div>
))}
</section>
);
}

View File

@ -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(<PalacePanel />);
await waitFor(() => expect(screen.getByText("Use JWT for auth")).toBeInTheDocument());
expect(screen.getByText("decisions")).toBeInTheDocument();
});
it("renders empty state", async () => {
mockListMemories.mockResolvedValue([]);
render(<PalacePanel />);
await waitFor(() =>
expect(screen.getByText(/No memories found/)).toBeInTheDocument(),
);
});
it("has search input", async () => {
mockListMemories.mockResolvedValue([]);
render(<PalacePanel />);
await waitFor(() => {
expect(screen.getByLabelText("Search palace memories")).toBeInTheDocument();
});
});
});

View File

@ -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<PalaceMemory[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(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<string, string> = {
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 (
<section className="surface-card" style={{ padding: "var(--nl-space-5)", display: "grid", gap: "var(--nl-space-3)" }}>
<div style={{ fontWeight: 700, fontSize: "var(--nl-text-lg)" }}>Palace Memory</div>
<div style={{ display: "flex", gap: "var(--nl-space-2)" }}>
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
placeholder="Search memories..."
aria-label="Search palace memories"
className="input"
style={{ flex: 1 }}
/>
<button onClick={handleSearch} className="btn btn-primary" aria-label="Search">
Search
</button>
</div>
{error && <div style={{ color: "var(--nl-status-error)" }}>{error}</div>}
{loading && <div style={{ color: "var(--nl-text-secondary)" }}>Loading...</div>}
{!loading && memories.length === 0 && (
<div style={{ color: "var(--nl-text-secondary)", textAlign: "center", padding: "var(--nl-space-4)" }}>
No memories found. Memories are automatically extracted from your notes.
</div>
)}
<div style={{ display: "grid", gap: "var(--nl-space-2)" }}>
{memories.map((mem) => (
<div
key={mem.id}
style={{
padding: "var(--nl-space-3)",
borderRadius: "var(--nl-radius-md)",
border: "1px solid var(--nl-border-subtle)",
display: "grid",
gap: "var(--nl-space-1)",
}}
>
<div style={{ display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<span
className="badge"
style={{ backgroundColor: hallColor[mem.hall] ?? "var(--nl-text-secondary)", color: "#fff", fontSize: "0.75rem" }}
>
{mem.hall}
</span>
<span style={{ color: "var(--nl-text-secondary)", fontSize: "0.75rem" }}>
{new Date(mem.createdAt).toLocaleDateString()}
</span>
</div>
<div>{mem.content}</div>
</div>
))}
</div>
</section>
);
}

View File

@ -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(<PalaceStats />);
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(<PalaceStats />);
await waitFor(() => expect(screen.getByText("Failed to load palace stats")).toBeInTheDocument());
});
it("renders loading state initially", () => {
mockGetPalaceStats.mockReturnValue(new Promise(() => {})); // never resolves
render(<PalaceStats />);
expect(screen.getByText("Loading stats...")).toBeInTheDocument();
});
});

View File

@ -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<PalaceStatsData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
void (async () => {
try {
const data = await getPalaceStats();
setStats(data);
} catch {
setError("Failed to load palace stats");
} finally {
setLoading(false);
}
})();
}, []);
if (loading) return <div style={{ color: "var(--nl-text-secondary)" }}>Loading stats...</div>;
if (error) return <div style={{ color: "var(--nl-status-error)" }}>{error}</div>;
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 (
<section className="surface-card" style={{ padding: "var(--nl-space-5)" }}>
<div style={{ fontWeight: 700, fontSize: "var(--nl-text-lg)", marginBottom: "var(--nl-space-3)" }}>
Memory Palace
</div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(100px, 1fr))", gap: "var(--nl-space-3)" }}>
{items.map(({ label, value }) => (
<div
key={label}
style={{
textAlign: "center",
padding: "var(--nl-space-3)",
borderRadius: "var(--nl-radius-md)",
border: "1px solid var(--nl-border-subtle)",
}}
>
<div style={{ fontSize: "1.5rem", fontWeight: 700, color: "var(--nl-accent)" }}>{value}</div>
<div style={{ fontSize: "0.75rem", color: "var(--nl-text-secondary)" }}>{label}</div>
</div>
))}
</div>
</section>
);
}

View File

@ -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 },
];

View File

@ -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);
});
});

View File

@ -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<PalaceMemory[]> {
const api = createNotesApiClient();
const params = new URLSearchParams({ q: query, limit: String(limit) });
if (wingId) params.set("wingId", wingId);
return api.fetch<PalaceMemory[]>(`/palace/search?${params.toString()}`);
}
export async function listWings(): Promise<PalaceWing[]> {
const api = createNotesApiClient();
return api.fetch<PalaceWing[]>("/palace/wings");
}
export async function getWingSummary(wingId: string): Promise<WingSummary> {
const api = createNotesApiClient();
return api.fetch<WingSummary>(`/palace/wings/${encodeURIComponent(wingId)}`);
}
export async function deleteWing(wingId: string): Promise<void> {
const api = createNotesApiClient();
await api.fetch(`/palace/wings/${encodeURIComponent(wingId)}`, { method: "DELETE" });
}
export async function listRooms(wingId: string): Promise<PalaceRoom[]> {
const api = createNotesApiClient();
return api.fetch<PalaceRoom[]>(`/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<PalaceMemory[]> {
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<PalaceMemory[]>(`/palace/memories?${params.toString()}`);
}
export async function deleteMemory(id: string): Promise<void> {
const api = createNotesApiClient();
await api.fetch(`/palace/memories/${encodeURIComponent(id)}`, { method: "DELETE" });
}
export async function queryEntity(entity: string): Promise<PalaceKGTriple[]> {
const api = createNotesApiClient();
return api.fetch<PalaceKGTriple[]>(`/palace/kg/entity/${encodeURIComponent(entity)}`);
}
export async function getEntityTimeline(entity: string): Promise<PalaceKGTriple[]> {
const api = createNotesApiClient();
return api.fetch<PalaceKGTriple[]>(`/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<WakeUpContext> {
const api = createNotesApiClient();
const params = task ? `?task=${encodeURIComponent(task)}` : "";
return api.fetch<WakeUpContext>(`/palace/wake-up/${encodeURIComponent(wingId)}${params}`);
}
export async function getPalaceStats(): Promise<PalaceStats> {
const api = createNotesApiClient();
return api.fetch<PalaceStats>("/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");
}