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
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -6,8 +6,8 @@
<meta name="theme-color" content="#0d1117" />
<link rel="manifest" href="/manifest.webmanifest" />
<title>Mission Control 2.0</title>
<script type="module" crossorigin src="/assets/index-CzNBzGRb.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DSNYfW6y.css">
<script type="module" crossorigin src="/assets/index-ByXSUESS.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CE_qxW4X.css">
</head>
<body>
<div id="root"></div>
+3 -1
View File
@@ -6,6 +6,7 @@ import { ModelsView } from "@/views/ModelsView"
import { RoutingView } from "@/views/RoutingView"
import { SystemView } from "@/views/SystemView"
import { ConnectView } from "@/views/ConnectView"
import { MemoryView } from "@/views/MemoryView"
import { Placeholder } from "@/views/Placeholder"
import { api, type Health } from "@/lib/api"
import { cn } from "@/lib/utils"
@@ -94,7 +95,8 @@ export default function App() {
{view === "routing" && <RoutingView />}
{view === "system" && <SystemView />}
{view === "connect" && <ConnectView />}
{!["models", "routing", "system", "connect"].includes(view) && (
{view === "memory" && <MemoryView />}
{!["models", "routing", "system", "connect", "memory"].includes(view) && (
<Placeholder title={active.label} hint={active.hint} />
)}
</main>
+16
View File
@@ -100,6 +100,22 @@ export interface ConnectResp {
tools: Record<string, ConnectTool>
}
export interface Memory {
id: string
content: string
category: string
source: string
created_at: string
updated_at: string
}
export interface DedupeResult {
groups: { keep: { id: string; content: string; category: string }; remove: { id: string; content: string }[] }[]
duplicate_count: number
removed: number
applied: boolean
}
export interface Health {
status: string
version: string
+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>
)
}