diff --git a/README.md b/README.md index ace378a..b53ff0e 100644 --- a/README.md +++ b/README.md @@ -362,6 +362,21 @@ All routes require `Authorization: Bearer `. `GET /health` is unauth | `GET /shared/log` | Cross-project feed of entries explicitly marked `global` | | `GET /shared/context` · `GET /shared/context/:key` | Read shared key/value facts | | `PUT /shared/context/:key` | Write a shared fact — requires the shared-write token | +| `GET /memory/notes` · `GET /memory/notes/:id` | Memory notes (`?project_id=` scopes, `?q=` searches) | +| `GET /memory/graph` | The whole note graph (notes + links) in one read | +| `POST`/`PATCH`/`DELETE /memory/notes…` · `POST`/`DELETE /memory/links…` | Author notes/links — requires the admin token | + +## Agent memory + +The distilled, linked knowledge layer over the raw log/transcript history: **notes** +(facts, decisions, gotchas, runbooks) scoped to a project or global, connected by +**links** into a graph the dashboard's Memory page draws. Every launch gets the bundled +`handler-memory` MCP server (injected into `--mcp-config` as +`python -m handler.mcpserver`, no operator setup), exposing `memory_search`, +`memory_get`, `memory_save`, and `memory_link`; a `SessionStart` hook injects the most +recent notes in scope, so knowledge from earlier runs arrives without being asked. +Notes live only in the database — like everything else, they survive disposable +workers by construction — and deleting an agent never deletes what it learned. ## Hooks @@ -382,6 +397,9 @@ Wired into each agent as `python -m handler.hooks `: is denied on the first failure. - **`Notification`** — POSTs a small JSON payload to `WEBHOOK_URL` (no-op when unset). Never blocks the agent on delivery failure. +- **`SessionStart`** — memory recall: injects the most recent memory notes in the + agent's scope (its project + global) as additional context, plus a pointer at the + handler-memory MCP tools. Best-effort; never blocks the session. Hook identity travels via environment variables injected at spawn (`HANDLER_AGENT_ID`, `HANDLER_PROJECT_ID`, `HANDLER_AGENT_NAME`, `HANDLER_AGENT_ROLE`, `DATABASE_URL`), since diff --git a/frontend/app/memory/page.tsx b/frontend/app/memory/page.tsx new file mode 100644 index 0000000..35a883a --- /dev/null +++ b/frontend/app/memory/page.tsx @@ -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 ( +
+ +
+ ); +} diff --git a/frontend/components/sections/MemorySection.tsx b/frontend/components/sections/MemorySection.tsx new file mode 100644 index 0000000..793b95b --- /dev/null +++ b/frontend/components/sections/MemorySection.tsx @@ -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 = { + 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 { + const pos = new Map(); + 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(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(null); + const [hoverId, setHoverId] = useState(null); + const [form, setForm] = useState(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(); + 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([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 ( + <> +
+
Memory
+
+ The web of notes agents and operators leave behind — facts, decisions, gotchas, + runbooks — and how they connect. +
+
+
+
+
+ n.id !== selected.id) + .map((n) => ({ value: String(n.id), label: n.title })), + ]} + /> +
+
+ +
+ + + +
+ + )} + + +
+ + {editing && selected ? `Edit note: ${selected.title}` : "New note"} + +
+
+ setForm((f) => ({ ...f, title: v }))} + placeholder="Short, searchable headline" + /> + setForm((f) => ({ ...f, project_id: v }))} + options={[ + { value: "", label: "Global" }, + ...s.projects.map((p) => ({ value: p.id, label: p.id })), + ]} + /> + +
+