feat(2.0): Phase 3 — Memory + MCP

Geteiltes Gedaechtnis: services/memory.py (SQLite/WAL, 5 Kategorien,
Dedupe-Kurator deterministisch), routers/memory.py (CRUD/export/dedupe).
MCP: mcp/mcp_memory.py (Guard-Beschreibungen gegen 14B-Loop) + mcp/mcp_mc.py
NEU (Stack-Management fuer Hermes: list/discover/register/route/restart/
status). Frontend MemoryView (Add/Filter/Suche/Delete/Aufraeumen).

Lokal verifiziert: CRUD + Dedupe (TestClient), MCP-Server syntax-OK,
Frontend-Build + Browser (MemoryView). Docs aktualisiert.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-06-25 08:16:12 +02:00
parent 6c8b6d81fe
commit 81468df9c0
17 changed files with 777 additions and 163 deletions
+139
View File
@@ -0,0 +1,139 @@
import { useEffect, useState } from "react"
import { Trash2, Sparkles } from "lucide-react"
import { api, type DedupeResult, type Memory } from "@/lib/api"
import { cn } from "@/lib/utils"
const CATEGORIES = ["user", "instruction", "stable", "versioned", "ephemeral"]
const CAT_LABEL: Record<string, string> = {
user: "👤 User", instruction: "📋 Regel", stable: "🔵 Fakt",
versioned: "🟡 Version", ephemeral: "⏱ Temporär",
}
export function MemoryView() {
const [items, setItems] = useState<Memory[]>([])
const [filter, setFilter] = useState("")
const [q, setQ] = useState("")
const [content, setContent] = useState("")
const [category, setCategory] = useState("stable")
const [error, setError] = useState("")
function load() {
const params = new URLSearchParams()
if (q) params.set("q", q)
if (filter) params.set("category", filter)
api<Memory[]>(`/api/memory?${params}`).then(setItems).catch((e) => setError(String(e)))
}
useEffect(load, [q, filter])
async function add() {
if (!content.trim()) return
await api("/api/memory", { method: "POST", body: JSON.stringify({ content, category, source: "ui" }) })
setContent("")
load()
}
async function del(id: string) {
await api(`/api/memory/${id}`, { method: "DELETE" })
load()
}
async function cleanup() {
const dry = await api<DedupeResult>("/api/memory/dedupe", {
method: "POST", body: JSON.stringify({ apply: false }),
})
if (dry.duplicate_count === 0) {
alert("Keine Dubletten gefunden — alles sauber.")
return
}
if (confirm(`${dry.duplicate_count} Dublette(n) in ${dry.groups.length} Gruppe(n) gefunden. Entfernen?`)) {
await api("/api/memory/dedupe", { method: "POST", body: JSON.stringify({ apply: true }) })
load()
}
}
return (
<div className="space-y-4">
<div className="flex items-start justify-between">
<div>
<h1 className="text-xl font-semibold">Gedächtnis</h1>
<p className="text-sm text-muted-foreground">
Die geteilte Verfassung" — alle Tools (Hermes, IDEs) lesen/schreiben hier via MCP.
</p>
</div>
<button
onClick={cleanup}
className="flex items-center gap-1.5 rounded-md border border-border px-2.5 py-1.5 text-sm hover:bg-accent"
>
<Sparkles className="h-3.5 w-3.5 text-primary" /> Aufräumen
</button>
</div>
{/* Add */}
<div className="rounded-xl border border-border bg-card p-3">
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder="Neuen Fakt / Regel hinzufügen"
rows={2}
className="w-full resize-none rounded-md border border-border bg-background px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-ring"
/>
<div className="mt-2 flex items-center gap-2">
<select
value={category}
onChange={(e) => setCategory(e.target.value)}
className="rounded-md border border-border bg-background px-2 py-1.5 text-sm outline-none"
>
{CATEGORIES.map((c) => (
<option key={c} value={c}>{CAT_LABEL[c]}</option>
))}
</select>
<button onClick={add} className="rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-primary-foreground hover:opacity-90">
Speichern
</button>
</div>
</div>
{/* Filter */}
<div className="flex flex-wrap items-center gap-2">
<input
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="Suchen"
className="rounded-md border border-border bg-card px-2 py-1.5 text-sm outline-none focus:ring-2 focus:ring-ring"
/>
<button
onClick={() => setFilter("")}
className={cn("rounded-md px-2.5 py-1.5 text-xs", !filter ? "bg-primary/15 text-primary" : "text-muted-foreground hover:text-foreground")}
>
Alle
</button>
{CATEGORIES.map((c) => (
<button
key={c}
onClick={() => setFilter(c)}
className={cn("rounded-md px-2.5 py-1.5 text-xs", filter === c ? "bg-primary/15 text-primary" : "text-muted-foreground hover:text-foreground")}
>
{CAT_LABEL[c]}
</button>
))}
</div>
{error && <div className="text-sm text-muted-foreground">Fehler: {error}</div>}
{/* List */}
<div className="space-y-2">
{items.length === 0 && <div className="text-sm text-muted-foreground">Keine Einträge.</div>}
{items.map((m) => (
<div key={m.id} className="flex items-start gap-3 rounded-lg border border-border bg-card p-3">
<span className="shrink-0 rounded bg-muted px-1.5 py-0.5 text-[11px] text-muted-foreground">
{CAT_LABEL[m.category] || m.category}
</span>
<span className="flex-1 text-sm">{m.content}</span>
<span className="shrink-0 text-[11px] text-muted-foreground">{m.source}</span>
<button onClick={() => del(m.id)} className="shrink-0 text-muted-foreground hover:text-red-500">
<Trash2 className="h-4 w-4" />
</button>
</div>
))}
</div>
</div>
)
}