mirror of
https://github.com/0xWheatyz/handler.git
synced 2026-08-30 04:36:24 +00:00
Add agent memory: linked note store, MCP server, recall hook, and graph UI
The distilled knowledge layer over the raw log/transcript history, inspired by TencentDB-Agent-Memory's memory-hub model, adapted to handler's invariants (state lives only in the database; workers stay stateless and disposable): - memory_notes + memory_links tables (migration 0014) with a portable DAL: scoped listing/search (project + global), idempotent linking, and a one-read graph. Deleting an agent nulls attribution but keeps its notes; deleting a project removes its notes and edges, leaving global knowledge intact. - /memory API routes: reads on the normal token, note/link authoring on the admin token, plus GET /memory/graph for the dashboard. - Bundled handler-memory MCP server (python -m handler.mcpserver), a dependency-free stdio JSON-RPC implementation injected into every launch's --mcp-config ahead of the DB connectors and allowlisted in generated settings, exposing memory_search / memory_get / memory_save / memory_link. Identity and DATABASE_URL arrive via the spawn env, same as hooks. - SessionStart recall hook: injects the most recent notes in scope as additional context at session start, best-effort, never blocking. - Memory page in the web UI: a hand-rolled force-directed SVG graph of the note web (colored by kind, hover highlights, click-through details), plus note/link authoring — the app's first visualization, no chart dependency. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WYqkoYPX8NAo1V2KyXr1pk
This commit is contained in:
@@ -0,0 +1,13 @@
|
||||
/* Memory page. The shell (sidebar, banners, store) comes from the root layout; this
|
||||
* route contributes only its section, in the shared scroll frame. */
|
||||
"use client";
|
||||
|
||||
import { MemorySection } from "@/components/sections/MemorySection";
|
||||
|
||||
export default function MemoryPage() {
|
||||
return (
|
||||
<div className="main-scroll">
|
||||
<MemorySection />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,508 @@
|
||||
/* Memory — the distilled knowledge agents leave behind, drawn as the web of notes.
|
||||
* Nodes are notes (colored by kind), edges are the links agents/operators asserted
|
||||
* between them. The layout is a small hand-rolled force simulation — deliberately no
|
||||
* chart library, matching the rest of the hand-rolled UI. Reads come from
|
||||
* GET /memory/graph via the store; writes are direct admin-token calls. */
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import type { MemoryLink, MemoryNote, NoteKind } from "@/lib/api";
|
||||
import { useDashboard, type MemoryNoteBody } from "@/components/store";
|
||||
import { Badge, Button, Card, Input, Select, Textarea } from "@/components/ui";
|
||||
import { fmtFull } from "@/lib/format";
|
||||
|
||||
const KIND_COLORS: Record<string, string> = {
|
||||
fact: "var(--lw-blue-400)",
|
||||
decision: "var(--lw-indigo-400)",
|
||||
gotcha: "var(--lw-warning-fg)",
|
||||
runbook: "var(--lw-success-fg)",
|
||||
};
|
||||
const KINDS: NoteKind[] = ["fact", "decision", "gotcha", "runbook"];
|
||||
|
||||
const W = 900;
|
||||
const H = 560;
|
||||
|
||||
interface Pos {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
/* Deterministic force layout: seed on a circle (stable order = stable picture), then
|
||||
* relax with link springs, pairwise repulsion, and a centering pull. Runs to a fixed
|
||||
* iteration budget synchronously — the graphs here are distilled notes, not big data. */
|
||||
function computeLayout(notes: MemoryNote[], links: MemoryLink[]): Map<number, Pos> {
|
||||
const pos = new Map<number, Pos>();
|
||||
const n = notes.length;
|
||||
if (n === 0) return pos;
|
||||
const cx = W / 2;
|
||||
const cy = H / 2;
|
||||
const seedR = Math.min(W, H) / 2 - 80;
|
||||
notes.forEach((note, i) => {
|
||||
const a = (2 * Math.PI * i) / n;
|
||||
pos.set(note.id, { x: cx + seedR * Math.cos(a), y: cy + seedR * Math.sin(a) });
|
||||
});
|
||||
const ids = notes.map((note) => note.id);
|
||||
const iterations = n > 150 ? 80 : 250;
|
||||
const springLen = 120;
|
||||
for (let it = 0; it < iterations; it++) {
|
||||
const temp = 1 - it / iterations; // cool down
|
||||
const force = new Map<number, Pos>(ids.map((id) => [id, { x: 0, y: 0 }]));
|
||||
// Pairwise repulsion.
|
||||
for (let i = 0; i < ids.length; i++) {
|
||||
for (let j = i + 1; j < ids.length; j++) {
|
||||
const a = pos.get(ids[i])!;
|
||||
const b = pos.get(ids[j])!;
|
||||
let dx = a.x - b.x;
|
||||
let dy = a.y - b.y;
|
||||
const d2 = Math.max(dx * dx + dy * dy, 1);
|
||||
const d = Math.sqrt(d2);
|
||||
const rep = 2600 / d2;
|
||||
dx = (dx / d) * rep;
|
||||
dy = (dy / d) * rep;
|
||||
const fa = force.get(ids[i])!;
|
||||
const fb = force.get(ids[j])!;
|
||||
fa.x += dx;
|
||||
fa.y += dy;
|
||||
fb.x -= dx;
|
||||
fb.y -= dy;
|
||||
}
|
||||
}
|
||||
// Link springs.
|
||||
for (const ln of links) {
|
||||
const a = pos.get(ln.src_note_id);
|
||||
const b = pos.get(ln.dst_note_id);
|
||||
if (!a || !b) continue;
|
||||
const dx = b.x - a.x;
|
||||
const dy = b.y - a.y;
|
||||
const d = Math.max(Math.sqrt(dx * dx + dy * dy), 1);
|
||||
const pull = (d - springLen) * 0.02;
|
||||
const fa = force.get(ln.src_note_id)!;
|
||||
const fb = force.get(ln.dst_note_id)!;
|
||||
fa.x += (dx / d) * pull;
|
||||
fa.y += (dy / d) * pull;
|
||||
fb.x -= (dx / d) * pull;
|
||||
fb.y -= (dy / d) * pull;
|
||||
}
|
||||
// Centering pull + apply.
|
||||
for (const id of ids) {
|
||||
const p = pos.get(id)!;
|
||||
const f = force.get(id)!;
|
||||
f.x += (cx - p.x) * 0.005;
|
||||
f.y += (cy - p.y) * 0.005;
|
||||
p.x += Math.max(-12, Math.min(12, f.x)) * temp;
|
||||
p.y += Math.max(-12, Math.min(12, f.y)) * temp;
|
||||
p.x = Math.max(30, Math.min(W - 30, p.x));
|
||||
p.y = Math.max(24, Math.min(H - 24, p.y));
|
||||
}
|
||||
}
|
||||
return pos;
|
||||
}
|
||||
|
||||
const emptyForm: MemoryNoteBody = { title: "", body: "", kind: "fact", project_id: "", tags: [] };
|
||||
|
||||
export function MemorySection() {
|
||||
const s = useDashboard();
|
||||
const [projectFilter, setProjectFilter] = useState("");
|
||||
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||
const [hoverId, setHoverId] = useState<number | null>(null);
|
||||
const [form, setForm] = useState<MemoryNoteBody>(emptyForm);
|
||||
const [tagsText, setTagsText] = useState("");
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [linkDst, setLinkDst] = useState("");
|
||||
const [linkRelation, setLinkRelation] = useState("relates_to");
|
||||
|
||||
const notes = useMemo(() => {
|
||||
if (!projectFilter) return s.memory.notes;
|
||||
if (projectFilter === "global") return s.memory.notes.filter((n) => !n.project_id);
|
||||
return s.memory.notes.filter(
|
||||
(n) => n.project_id === projectFilter || !n.project_id,
|
||||
);
|
||||
}, [s.memory.notes, projectFilter]);
|
||||
|
||||
const links = useMemo(() => {
|
||||
const inScope = new Set(notes.map((n) => n.id));
|
||||
return s.memory.links.filter(
|
||||
(ln) => inScope.has(ln.src_note_id) && inScope.has(ln.dst_note_id),
|
||||
);
|
||||
}, [s.memory.links, notes]);
|
||||
|
||||
/* Recompute only when the graph's shape actually changes, not on every poll. */
|
||||
const layoutKey = useMemo(
|
||||
() =>
|
||||
notes.map((n) => n.id).join(",") +
|
||||
"|" +
|
||||
links.map((ln) => ln.id).join(","),
|
||||
[notes, links],
|
||||
);
|
||||
const layout = useMemo(
|
||||
() => computeLayout(notes, links),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[layoutKey],
|
||||
);
|
||||
|
||||
const degree = useMemo(() => {
|
||||
const d = new Map<number, number>();
|
||||
for (const ln of links) {
|
||||
d.set(ln.src_note_id, (d.get(ln.src_note_id) ?? 0) + 1);
|
||||
d.set(ln.dst_note_id, (d.get(ln.dst_note_id) ?? 0) + 1);
|
||||
}
|
||||
return d;
|
||||
}, [links]);
|
||||
|
||||
const selected = notes.find((n) => n.id === selectedId) ?? null;
|
||||
const focusId = hoverId ?? selectedId;
|
||||
const neighborhood = useMemo(() => {
|
||||
if (focusId == null) return null;
|
||||
const set = new Set<number>([focusId]);
|
||||
for (const ln of links) {
|
||||
if (ln.src_note_id === focusId) set.add(ln.dst_note_id);
|
||||
if (ln.dst_note_id === focusId) set.add(ln.src_note_id);
|
||||
}
|
||||
return set;
|
||||
}, [focusId, links]);
|
||||
|
||||
const selectedLinks = useMemo(
|
||||
() =>
|
||||
selected
|
||||
? links.filter(
|
||||
(ln) => ln.src_note_id === selected.id || ln.dst_note_id === selected.id,
|
||||
)
|
||||
: [],
|
||||
[selected, links],
|
||||
);
|
||||
|
||||
const titleOf = (id: number) => s.memory.notes.find((n) => n.id === id)?.title ?? `#${id}`;
|
||||
|
||||
const parseTags = (text: string) =>
|
||||
text
|
||||
.split(",")
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const saveNote = async () => {
|
||||
const body = { ...form, tags: parseTags(tagsText) };
|
||||
const ok =
|
||||
editing && selected
|
||||
? await s.updateMemoryNote(selected.id, body)
|
||||
: await s.createMemoryNote(body);
|
||||
if (ok) {
|
||||
setForm(emptyForm);
|
||||
setTagsText("");
|
||||
setEditing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const startEdit = () => {
|
||||
if (!selected) return;
|
||||
setForm({
|
||||
title: selected.title,
|
||||
body: selected.body,
|
||||
kind: (KINDS.includes(selected.kind as NoteKind) ? selected.kind : "fact") as NoteKind,
|
||||
project_id: selected.project_id ?? "",
|
||||
tags: selected.tags ?? [],
|
||||
});
|
||||
setTagsText((selected.tags ?? []).join(", "));
|
||||
setEditing(true);
|
||||
};
|
||||
|
||||
const addLink = async () => {
|
||||
if (!selected || !linkDst) return;
|
||||
const ok = await s.createMemoryLink(selected.id, Number(linkDst), linkRelation.trim());
|
||||
if (ok) setLinkDst("");
|
||||
};
|
||||
|
||||
const projectOptions = [
|
||||
{ value: "", label: "All projects" },
|
||||
{ value: "global", label: "Global only" },
|
||||
...s.projects.map((p) => ({ value: p.id, label: p.id })),
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="section-head">
|
||||
<div className="section-title">Memory</div>
|
||||
<div className="section-desc">
|
||||
The web of notes agents and operators leave behind — facts, decisions, gotchas,
|
||||
runbooks — and how they connect.
|
||||
</div>
|
||||
</div>
|
||||
<div className="section-body">
|
||||
<div className="hstack wrap" style={{ alignItems: "flex-end", gap: 12 }}>
|
||||
<div style={{ width: 220 }}>
|
||||
<Select
|
||||
label="Scope"
|
||||
value={projectFilter}
|
||||
onChange={setProjectFilter}
|
||||
options={projectOptions}
|
||||
/>
|
||||
</div>
|
||||
<div className="hstack" style={{ gap: 8, paddingBottom: 6 }}>
|
||||
{KINDS.map((k) => (
|
||||
<span key={k} className="hstack" style={{ gap: 5, alignItems: "center" }}>
|
||||
<span
|
||||
style={{
|
||||
width: 9,
|
||||
height: 9,
|
||||
borderRadius: "50%",
|
||||
background: KIND_COLORS[k],
|
||||
display: "inline-block",
|
||||
}}
|
||||
/>
|
||||
<span className="faint" style={{ fontSize: "var(--text-xs)" }}>
|
||||
{k}
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{notes.length === 0 ? (
|
||||
<div className="empty">
|
||||
No memory notes yet. Agents write them with the handler-memory MCP tools
|
||||
(memory_save / memory_link); you can add one below.
|
||||
</div>
|
||||
) : (
|
||||
<Card>
|
||||
<svg
|
||||
viewBox={`0 0 ${W} ${H}`}
|
||||
style={{ width: "100%", height: "auto", display: "block" }}
|
||||
role="img"
|
||||
aria-label="Graph of memory notes and their links"
|
||||
>
|
||||
{links.map((ln) => {
|
||||
const a = layout.get(ln.src_note_id);
|
||||
const b = layout.get(ln.dst_note_id);
|
||||
if (!a || !b) return null;
|
||||
const focused =
|
||||
focusId != null &&
|
||||
(ln.src_note_id === focusId || ln.dst_note_id === focusId);
|
||||
return (
|
||||
<g key={ln.id}>
|
||||
<line
|
||||
x1={a.x}
|
||||
y1={a.y}
|
||||
x2={b.x}
|
||||
y2={b.y}
|
||||
stroke={focused ? "var(--lw-blue-300)" : "var(--border-strong)"}
|
||||
strokeWidth={focused ? 1.6 : 1}
|
||||
opacity={neighborhood && !focused ? 0.25 : 0.8}
|
||||
/>
|
||||
{focused && (
|
||||
<text
|
||||
x={(a.x + b.x) / 2}
|
||||
y={(a.y + b.y) / 2 - 4}
|
||||
textAnchor="middle"
|
||||
fill="var(--text-faint)"
|
||||
fontSize={10}
|
||||
>
|
||||
{ln.relation}
|
||||
</text>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
{notes.map((n) => {
|
||||
const p = layout.get(n.id);
|
||||
if (!p) return null;
|
||||
const r = 6 + Math.min(degree.get(n.id) ?? 0, 6);
|
||||
const dimmed = neighborhood != null && !neighborhood.has(n.id);
|
||||
const isSelected = n.id === selectedId;
|
||||
return (
|
||||
<g
|
||||
key={n.id}
|
||||
transform={`translate(${p.x},${p.y})`}
|
||||
style={{ cursor: "pointer" }}
|
||||
opacity={dimmed ? 0.3 : 1}
|
||||
onMouseEnter={() => setHoverId(n.id)}
|
||||
onMouseLeave={() => setHoverId(null)}
|
||||
onClick={() => {
|
||||
setSelectedId(n.id === selectedId ? null : n.id);
|
||||
setEditing(false);
|
||||
}}
|
||||
>
|
||||
<circle
|
||||
r={r}
|
||||
fill={KIND_COLORS[n.kind] ?? "var(--lw-neutral-fg)"}
|
||||
stroke={isSelected ? "var(--lw-white)" : "var(--surface-page)"}
|
||||
strokeWidth={isSelected ? 2 : 1}
|
||||
/>
|
||||
<text
|
||||
y={r + 12}
|
||||
textAnchor="middle"
|
||||
fill={dimmed ? "var(--text-faint)" : "var(--text-muted)"}
|
||||
fontSize={10.5}
|
||||
>
|
||||
{n.title.length > 24 ? n.title.slice(0, 24) + "…" : n.title}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{selected && (
|
||||
<Card>
|
||||
<div className="card-head" style={{ marginBottom: 10 }}>
|
||||
<span
|
||||
className="card-title"
|
||||
style={{ fontSize: "var(--text-md)", color: "var(--text-heading)" }}
|
||||
>
|
||||
{selected.title}
|
||||
</span>
|
||||
<span className="hstack" style={{ gap: 8 }}>
|
||||
<Badge>{selected.kind}</Badge>
|
||||
<Badge>{selected.project_id ?? "global"}</Badge>
|
||||
</span>
|
||||
</div>
|
||||
<p style={{ whiteSpace: "pre-wrap", margin: 0 }}>{selected.body}</p>
|
||||
<p className="faint" style={{ fontSize: "var(--text-xs)", margin: "10px 0 0" }}>
|
||||
{selected.agent_id != null ? `written by agent ${selected.agent_id}` : "operator-authored"}
|
||||
{" · updated "}
|
||||
{fmtFull(selected.updated_at)}
|
||||
{selected.tags?.length ? ` · tags: ${selected.tags.join(", ")}` : ""}
|
||||
</p>
|
||||
|
||||
{selectedLinks.length > 0 && (
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<div className="eyebrow" style={{ marginBottom: 6 }}>
|
||||
Links
|
||||
</div>
|
||||
{selectedLinks.map((ln) => {
|
||||
const otherId =
|
||||
ln.src_note_id === selected.id ? ln.dst_note_id : ln.src_note_id;
|
||||
const outgoing = ln.src_note_id === selected.id;
|
||||
return (
|
||||
<div key={ln.id} className="hstack" style={{ gap: 8, marginBottom: 4 }}>
|
||||
<span className="faint" style={{ fontSize: "var(--text-xs)" }}>
|
||||
{outgoing ? `${ln.relation} →` : `← ${ln.relation}`}
|
||||
</span>
|
||||
<a
|
||||
href="#"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
setSelectedId(otherId);
|
||||
}}
|
||||
>
|
||||
{titleOf(otherId)}
|
||||
</a>
|
||||
<Button size="sm" variant="ghost" onClick={() => s.deleteMemoryLink(ln.id)}>
|
||||
remove
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="hstack wrap mt14" style={{ gap: 8, alignItems: "flex-end" }}>
|
||||
<div style={{ width: 260 }}>
|
||||
<Select
|
||||
label="Link to"
|
||||
value={linkDst}
|
||||
onChange={setLinkDst}
|
||||
options={[
|
||||
{ value: "", label: "— pick a note —" },
|
||||
...notes
|
||||
.filter((n) => n.id !== selected.id)
|
||||
.map((n) => ({ value: String(n.id), label: n.title })),
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ width: 160 }}>
|
||||
<Input label="Relation" value={linkRelation} onChange={setLinkRelation} />
|
||||
</div>
|
||||
<Button disabled={!linkDst} onClick={addLink}>
|
||||
Link
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={startEdit}>
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={async () => {
|
||||
await s.deleteMemoryNote(selected.id);
|
||||
setSelectedId(null);
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<div className="card-head" style={{ marginBottom: 14 }}>
|
||||
<span
|
||||
className="card-title"
|
||||
style={{ fontSize: "var(--text-md)", color: "var(--text-heading)" }}
|
||||
>
|
||||
{editing && selected ? `Edit note: ${selected.title}` : "New note"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="form-grid">
|
||||
<Input
|
||||
label="Title"
|
||||
value={form.title}
|
||||
onChange={(v) => setForm((f) => ({ ...f, title: v }))}
|
||||
placeholder="Short, searchable headline"
|
||||
/>
|
||||
<Select
|
||||
label="Kind"
|
||||
value={form.kind}
|
||||
onChange={(v) => setForm((f) => ({ ...f, kind: v as NoteKind }))}
|
||||
options={KINDS.map((k) => ({ value: k, label: k }))}
|
||||
/>
|
||||
<Select
|
||||
label="Project"
|
||||
value={form.project_id}
|
||||
onChange={(v) => setForm((f) => ({ ...f, project_id: v }))}
|
||||
options={[
|
||||
{ value: "", label: "Global" },
|
||||
...s.projects.map((p) => ({ value: p.id, label: p.id })),
|
||||
]}
|
||||
/>
|
||||
<Input
|
||||
label="Tags (comma-separated)"
|
||||
value={tagsText}
|
||||
onChange={setTagsText}
|
||||
placeholder="auth, deploy"
|
||||
/>
|
||||
</div>
|
||||
<Textarea
|
||||
label="Body"
|
||||
value={form.body}
|
||||
onChange={(v) => setForm((f) => ({ ...f, body: v }))}
|
||||
rows={5}
|
||||
placeholder="The knowledge itself — written for a reader with no context."
|
||||
/>
|
||||
<div className="hstack mt14" style={{ gap: 8 }}>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={!form.title.trim() || !form.body.trim()}
|
||||
onClick={saveNote}
|
||||
>
|
||||
{editing ? "Save changes" : "Add note"}
|
||||
</Button>
|
||||
{editing && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setEditing(false);
|
||||
setForm(emptyForm);
|
||||
setTagsText("");
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<p className="faint" style={{ fontSize: "var(--text-xs)", margin: "10px 0 0" }}>
|
||||
Writes require the admin token. Agents add notes themselves through the bundled
|
||||
handler-memory MCP server.
|
||||
</p>
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -29,6 +29,8 @@ import {
|
||||
type Command,
|
||||
type Host,
|
||||
type LogEntry,
|
||||
type MemoryGraph,
|
||||
type NoteKind,
|
||||
type Project,
|
||||
type Schedule,
|
||||
type SharedContext,
|
||||
@@ -43,6 +45,7 @@ export type Section =
|
||||
| "servers"
|
||||
| "activity"
|
||||
| "shared"
|
||||
| "memory"
|
||||
| "claude";
|
||||
|
||||
/* The claude web-login flow, driven through the login_start / login_submit commands.
|
||||
@@ -98,6 +101,8 @@ interface StoreValue {
|
||||
commands: Command[];
|
||||
schedules: Schedule[];
|
||||
shared: { log: LogEntry[]; context: SharedContext[] };
|
||||
/* The whole note graph (scoped client-side by the Memory section's project filter). */
|
||||
memory: MemoryGraph;
|
||||
|
||||
cmd: CmdState;
|
||||
lastError: string;
|
||||
@@ -124,6 +129,13 @@ interface StoreValue {
|
||||
pollCi: () => Promise<void>;
|
||||
setSharedKey: (key: string, value: string) => Promise<boolean>;
|
||||
|
||||
// Memory (notes + links; direct writes like the Claude management pages)
|
||||
createMemoryNote: (b: MemoryNoteBody) => Promise<boolean>;
|
||||
updateMemoryNote: (id: number, b: Partial<MemoryNoteBody>) => Promise<boolean>;
|
||||
deleteMemoryNote: (id: number) => Promise<void>;
|
||||
createMemoryLink: (src: number, dst: number, relation: string) => Promise<boolean>;
|
||||
deleteMemoryLink: (id: number) => Promise<void>;
|
||||
|
||||
// Claude web-login
|
||||
claudeLogin: ClaudeLoginState;
|
||||
startClaudeLogin: () => Promise<void>;
|
||||
@@ -252,6 +264,15 @@ export interface HostBody {
|
||||
generate_ssh_key: boolean;
|
||||
}
|
||||
|
||||
export interface MemoryNoteBody {
|
||||
title: string;
|
||||
body: string;
|
||||
kind: NoteKind;
|
||||
/* Project scope as a select value; "" = a global note. */
|
||||
project_id: string;
|
||||
tags: string[];
|
||||
}
|
||||
|
||||
const Ctx = createContext<StoreValue | null>(null);
|
||||
|
||||
export function useDashboard(): StoreValue {
|
||||
@@ -302,6 +323,7 @@ export function DashboardProvider({
|
||||
log: [],
|
||||
context: [],
|
||||
});
|
||||
const [memory, setMemory] = useState<MemoryGraph>({ notes: [], links: [] });
|
||||
const [cmd, setCmd] = useState<CmdState>({ text: "", error: false, busy: false });
|
||||
const [lastError, setLastError] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -453,6 +475,14 @@ export function DashboardProvider({
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadMemory = useCallback(async () => {
|
||||
try {
|
||||
setMemory(await clientRef.current.api<MemoryGraph>("/memory/graph"));
|
||||
} catch (e) {
|
||||
swallow(e);
|
||||
}
|
||||
}, []);
|
||||
|
||||
/* Models load separately from the rest of the Claude page: the Agents section's spawn
|
||||
* dropdown needs them too, without dragging skills/connectors along. */
|
||||
const loadClaudeModels = useCallback(async () => {
|
||||
@@ -505,8 +535,9 @@ export function DashboardProvider({
|
||||
if (s === "activity") await loadCommands();
|
||||
if (s === "schedules") await loadSchedules();
|
||||
if (s === "shared") await loadShared();
|
||||
if (s === "memory") await loadMemory();
|
||||
if (s === "claude") await loadClaude();
|
||||
}, [loadAgents, loadRun, loadApprovals, loadHosts, loadCommands, loadSchedules, loadShared, loadClaude, loadClaudeModels]);
|
||||
}, [loadAgents, loadRun, loadApprovals, loadHosts, loadCommands, loadSchedules, loadShared, loadMemory, loadClaude, loadClaudeModels]);
|
||||
|
||||
// Initial load + polling loop. The first tick populates projects *and* agents (and the
|
||||
// active section) up front, so the Runs inbox is filled without waiting a poll interval.
|
||||
@@ -536,9 +567,10 @@ export function DashboardProvider({
|
||||
if (s === "activity") void loadCommands();
|
||||
if (s === "schedules") void loadSchedules();
|
||||
if (s === "shared") void loadShared();
|
||||
if (s === "memory") void loadMemory();
|
||||
if (s === "claude") void loadClaude();
|
||||
},
|
||||
[loadApprovals, loadHosts, loadCommands, loadSchedules, loadShared, loadClaude, loadClaudeModels],
|
||||
[loadApprovals, loadHosts, loadCommands, loadSchedules, loadShared, loadMemory, loadClaude, loadClaudeModels],
|
||||
);
|
||||
|
||||
const selectProject = useCallback(
|
||||
@@ -1281,6 +1313,92 @@ export function DashboardProvider({
|
||||
[loadShared],
|
||||
);
|
||||
|
||||
// ---- memory actions (direct writes, admin token; mirror the shared/claude style) ----
|
||||
const memoryWrite = useCallback(
|
||||
async (fn: () => Promise<unknown>, okText: string) => {
|
||||
try {
|
||||
await fn();
|
||||
setCmd({ text: okText, error: false, busy: false });
|
||||
await loadMemory();
|
||||
return true;
|
||||
} catch (e) {
|
||||
if (e instanceof AuthError) return false;
|
||||
setCmd({ text: (e as Error).message, error: true, busy: false });
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[loadMemory],
|
||||
);
|
||||
|
||||
const createMemoryNote = useCallback(
|
||||
(b: MemoryNoteBody) =>
|
||||
memoryWrite(
|
||||
() =>
|
||||
clientRef.current.api("/memory/notes", {
|
||||
body: {
|
||||
title: b.title,
|
||||
body: b.body,
|
||||
kind: b.kind,
|
||||
project_id: b.project_id || null,
|
||||
tags: b.tags.length ? b.tags : null,
|
||||
},
|
||||
}),
|
||||
"note saved",
|
||||
),
|
||||
[memoryWrite],
|
||||
);
|
||||
|
||||
const updateMemoryNote = useCallback(
|
||||
(id: number, b: Partial<MemoryNoteBody>) =>
|
||||
memoryWrite(
|
||||
() =>
|
||||
clientRef.current.api(`/memory/notes/${id}`, {
|
||||
method: "PATCH",
|
||||
body: {
|
||||
...(b.title !== undefined ? { title: b.title } : {}),
|
||||
...(b.body !== undefined ? { body: b.body } : {}),
|
||||
...(b.kind !== undefined ? { kind: b.kind } : {}),
|
||||
...(b.project_id !== undefined ? { project_id: b.project_id || null } : {}),
|
||||
...(b.tags !== undefined ? { tags: b.tags.length ? b.tags : null } : {}),
|
||||
},
|
||||
}),
|
||||
"note updated",
|
||||
),
|
||||
[memoryWrite],
|
||||
);
|
||||
|
||||
const deleteMemoryNote = useCallback(
|
||||
async (id: number) => {
|
||||
await memoryWrite(
|
||||
() => clientRef.current.api(`/memory/notes/${id}`, { method: "DELETE" }),
|
||||
"note deleted",
|
||||
);
|
||||
},
|
||||
[memoryWrite],
|
||||
);
|
||||
|
||||
const createMemoryLink = useCallback(
|
||||
(src: number, dst: number, relation: string) =>
|
||||
memoryWrite(
|
||||
() =>
|
||||
clientRef.current.api("/memory/links", {
|
||||
body: { src_note_id: src, dst_note_id: dst, relation: relation || "relates_to" },
|
||||
}),
|
||||
"notes linked",
|
||||
),
|
||||
[memoryWrite],
|
||||
);
|
||||
|
||||
const deleteMemoryLink = useCallback(
|
||||
async (id: number) => {
|
||||
await memoryWrite(
|
||||
() => clientRef.current.api(`/memory/links/${id}`, { method: "DELETE" }),
|
||||
"link removed",
|
||||
);
|
||||
},
|
||||
[memoryWrite],
|
||||
);
|
||||
|
||||
const value: StoreValue = {
|
||||
section,
|
||||
setSection,
|
||||
@@ -1301,6 +1419,7 @@ export function DashboardProvider({
|
||||
commands,
|
||||
schedules,
|
||||
shared,
|
||||
memory,
|
||||
cmd,
|
||||
lastError,
|
||||
loading,
|
||||
@@ -1322,6 +1441,11 @@ export function DashboardProvider({
|
||||
deleteSchedule,
|
||||
pollCi,
|
||||
setSharedKey,
|
||||
createMemoryNote,
|
||||
updateMemoryNote,
|
||||
deleteMemoryNote,
|
||||
createMemoryLink,
|
||||
deleteMemoryLink,
|
||||
claudeLogin,
|
||||
startClaudeLogin,
|
||||
submitClaudeCode,
|
||||
|
||||
@@ -209,6 +209,37 @@ export interface ClaudePermissions {
|
||||
base_allow: string[];
|
||||
}
|
||||
|
||||
/* ---- agent memory (the Memory page's note graph) ---- */
|
||||
|
||||
export type NoteKind = "fact" | "decision" | "gotcha" | "runbook";
|
||||
|
||||
export interface MemoryNote {
|
||||
id: number;
|
||||
project_id?: string | null; // null = global note
|
||||
agent_id?: number | null; // authoring agent; null = operator-authored
|
||||
title: string;
|
||||
body: string;
|
||||
kind: string;
|
||||
tags?: string[] | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface MemoryLink {
|
||||
id: number;
|
||||
src_note_id: number;
|
||||
dst_note_id: number;
|
||||
relation: string;
|
||||
created_by_agent_id?: number | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
/* Everything the graph view draws, in one response (GET /memory/graph). */
|
||||
export interface MemoryGraph {
|
||||
notes: MemoryNote[];
|
||||
links: MemoryLink[];
|
||||
}
|
||||
|
||||
export interface SharedContext {
|
||||
key: string;
|
||||
value: string;
|
||||
|
||||
@@ -18,6 +18,7 @@ export const NAV_ROUTES: NavRoute[] = [
|
||||
{ key: "servers", href: "/servers", label: "Git Servers" },
|
||||
{ key: "activity", href: "/activity", label: "Activity" },
|
||||
{ key: "shared", href: "/shared", label: "Shared" },
|
||||
{ key: "memory", href: "/memory", label: "Memory" },
|
||||
{ key: "claude", href: "/claude", label: "Claude" },
|
||||
];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user