feat(memory): v7 — Gedächtnis-Layer mit MCP-Integration

SQLite-basiertes Memory-System fuer persistentes Gedaechtnis ueber Sessions.
Cline, OpenCode und Claude Code teilen denselben Speicher via MCP-Server.

- routers/memory.py: CRUD + Export-Endpoint (GET/POST/PUT/DELETE /api/memory)
- mcp_memory.py: stdio MCP-Server — Tools: get/add/search/update/delete_memory
- MemoryPanel.svelte: Gedaechtnis-Tab mit Filter, Inline-Edit, Add-Formular
- ConnectPanel.svelte: Gedaechtnis-MCP Setup-Guide (Cline/OpenCode/Bosgame)
- Temporaere Eintraege (ephemeral) nach 7 Tagen automatisch geloescht

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-06-23 13:30:47 +02:00
parent 8b52cd20b4
commit e02a1889b2
10 changed files with 617 additions and 28 deletions
+2
View File
@@ -15,6 +15,7 @@ import CookbookPanel from './panels/CookbookPanel.svelte'
import ConnectPanel from './panels/ConnectPanel.svelte'
import NewsPanel from './panels/NewsPanel.svelte'
import GuidesPanel from './panels/GuidesPanel.svelte'
import MemoryPanel from './panels/MemoryPanel.svelte'
let prevModelStates: Record<string, string> = {}
@@ -156,6 +157,7 @@ const panels: [string, any][] = [
['connect', ConnectPanel],
['news', NewsPanel],
['guides', GuidesPanel],
['memory', MemoryPanel],
]
for (const [view, Panel] of panels) {
const el = document.querySelector(`.view[data-view="${view}"]`)
+65
View File
@@ -11,6 +11,8 @@
let selectedModelIds = $state<string[]>([])
let testResult = $state('')
let testOk = $state(false)
let selectedMemTool = $state('cline')
const memBlock = $derived(memMcpBlock(selectedMemTool))
function baseUrl() { return `http://${location.hostname}:8080/v1` }
function isLanIp(h: string) { return /^\d{1,3}(\.\d{1,3}){3}$/.test(h) }
@@ -71,6 +73,16 @@
function copyVal(inp: HTMLInputElement) {
navigator.clipboard?.writeText(inp.value)
}
function memMcpBlock(tool: string) {
const pyPath = tool === 'bosgame'
? '/opt/mission-control/.venv/bin/python'
: 'python'
const scriptPath = tool === 'bosgame'
? '/opt/mission-control/mcp_memory.py'
: 'C:\\\\Users\\\\TobisPC\\\\mission-control\\\\mcp_memory.py'
return `{\n "mcpServers": {\n "mission-control-memory": {\n "command": "${pyPath}",\n "args": ["${scriptPath}"],\n "env": {\n "MC_URL": "http://192.168.178.151:9000"\n }\n }\n }\n}`
}
</script>
<div class="pagehead">
@@ -308,3 +320,56 @@
</div>
</div>
{/if}
<!-- Memory MCP — unabhaengig von Modellen, immer sichtbar -->
<div class="card">
<div class="card-h">
<h3>Gedächtnis-MCP</h3>
<span class="chip" style="background:rgba(45,212,191,.1);border-color:rgba(45,212,191,.3);color:var(--accent)">Neu in v7</span>
</div>
<div class="card-sub" style="margin-bottom:14px">
Damit Cline, OpenCode und Claude Code sich Projektfakten, Entscheidungen und Präferenzen
über Sessions hinweg merken — geteilt über alle Tools, verwaltet im
<a href="#"
onclick={e => { e.preventDefault(); (document.querySelector(".nav-item[data-view='memory']") as HTMLElement)?.click() }}
style="color:var(--accent)">Gedächtnis-Tab</a>.
</div>
<!-- Tool selector -->
<div class="flex gap-2" style="margin-bottom:14px;flex-wrap:wrap">
{#each [
['cline', 'Cline / Claude Code', 'Windows — Tools auf dem Entwicklungs-PC'],
['opencode', 'OpenCode', 'Windows — Terminal-Agent'],
['bosgame', 'Direkt am Bosgame', 'Wenn das Tool am Server läuft'],
] as [id, label, desc]}
<button class="card-btn{selectedMemTool === id ? ' cb-best' : ''}"
style="text-align:left;padding:10px 14px"
onclick={() => selectedMemTool = id}>
<div style="font-weight:500;font-size:13px">{label}</div>
<div class="card-sub" style="margin:2px 0 0;font-size:11.5px">{desc}</div>
</button>
{/each}
</div>
<!-- Config block -->
<div style="position:relative">
<button class="ghost" style="position:absolute;top:8px;right:8px;z-index:1"
onclick={e => copyCode(e.currentTarget!.parentElement!)}>Kopieren</button>
<div class="log" style="max-height:none"><code>{memBlock}</code></div>
</div>
{#if selectedMemTool === 'cline'}
<p style="margin-top:12px">In <b>Cline</b>: MCP-Einstellungen öffnen (Stecker-Icon in der Cline-Sidebar) → „Add MCP Server" → diesen Block einfügen.</p>
<p>In <b>Claude Code</b>: Block in <code>~/.claude/settings.json</code> unter <code>"mcpServers"</code> einfügen.</p>
<div class="alert warn" style="margin-top:8px;padding:10px 14px"><span class="a-dot"></span>
<span>Einmalig auf dem Windows-PC: <code>pip install mcp httpx</code> ausführen — danach <code>mcp_memory.py</code> aus dem Repo-Ordner erreichbar machen (Pfad im Snippet anpassen).</span>
</div>
{:else if selectedMemTool === 'opencode'}
<p style="margin-top:12px">Block in <code>~/.config/opencode/opencode.json</code> unter <code>"mcpServers"</code> einfügen.</p>
<div class="alert warn" style="margin-top:8px;padding:10px 14px"><span class="a-dot"></span>
<span>Einmalig: <code>pip install mcp httpx</code> — und Pfad zu <code>mcp_memory.py</code> im Snippet anpassen.</span>
</div>
{:else}
<p style="margin-top:12px">Block in die MCP-Config des jeweiligen Tools einfügen. Der Bosgame-Pfad <code>/opt/mission-control/mcp_memory.py</code> ist bereits korrekt — kein weiteres Setup nötig.</p>
{/if}
</div>
+237
View File
@@ -0,0 +1,237 @@
<script lang="ts">
import { api } from '@core/api.js'
import { toast, confirmModal } from '@core/ui.js'
type Mem = {
id: string
content: string
category: string
source: string
created_at: string
updated_at: string
}
let memories = $state<Mem[]>([])
let loading = $state(true)
let filter = $state('all')
let adding = $state(false)
let newContent = $state('')
let newCategory = $state('stable')
let editId = $state<string | null>(null)
let editContent = $state('')
let editCategory = $state('stable')
const filtered = $derived(
filter === 'all' ? memories : memories.filter(m => m.category === filter)
)
const CAT: Record<string, { label: string; color: string }> = {
stable: { label: 'Stabil', color: 'var(--accent)' },
versioned: { label: 'Versioniert', color: '#f59e0b' },
ephemeral: { label: 'Temporär', color: 'var(--mut)' },
}
function relTime(iso: string) {
const s = Math.floor((Date.now() - new Date(iso).getTime()) / 1000)
if (s < 60) return 'gerade eben'
if (s < 3600) return `vor ${Math.floor(s / 60)} Min`
if (s < 86400) return `vor ${Math.floor(s / 3600)} Std`
return `vor ${Math.floor(s / 86400)} Tagen`
}
function countCat(cat: string) {
return memories.filter(m => m.category === cat).length
}
async function load() {
try { memories = await api('/api/memory') }
catch (e: any) { toast('Laden fehlgeschlagen: ' + e.message, true) }
finally { loading = false }
}
async function addMem() {
if (!newContent.trim()) return
try {
const m = await api('/api/memory', {
method: 'POST',
body: JSON.stringify({ content: newContent.trim(), category: newCategory, source: 'manual' })
})
memories = [m, ...memories]
newContent = ''; newCategory = 'stable'; adding = false
toast('Gespeichert')
} catch (e: any) { toast('Fehler: ' + e.message, true) }
}
async function saveMem(m: Mem) {
try {
const updated = await api(`/api/memory/${m.id}`, {
method: 'PUT',
body: JSON.stringify({ content: editContent, category: editCategory })
})
memories = memories.map(x => x.id === m.id ? updated : x)
editId = null
toast('Aktualisiert')
} catch (e: any) { toast('Fehler: ' + e.message, true) }
}
async function delMem(m: Mem) {
const ok = await confirmModal({
title: 'Eintrag löschen?',
body: `„${m.content.length > 80 ? m.content.slice(0, 80) + '…' : m.content}" wird dauerhaft entfernt.`,
danger: true
})
if (!ok) return
try {
await api(`/api/memory/${m.id}`, { method: 'DELETE' })
memories = memories.filter(x => x.id !== m.id)
toast('Gelöscht')
} catch (e: any) { toast('Fehler: ' + e.message, true) }
}
function startEdit(m: Mem) {
editId = m.id; editContent = m.content; editCategory = m.category
}
$effect(() => { load() })
</script>
<div class="pagehead" style="display:flex;justify-content:space-between;align-items:flex-start">
<div>
<h1>Gedächtnis</h1>
<div class="sub">Fakten, Entscheidungen und Kontext — geteilt von allen KI-Tools via MCP.</div>
</div>
<button class="primary" style="flex-shrink:0;margin-top:4px"
onclick={() => { adding = !adding; newContent = '' }}>
{adding ? 'Abbrechen' : '+ Eintrag'}
</button>
</div>
<!-- Add form -->
{#if adding}
<div class="card">
<div class="card-h"><h3>Neuer Eintrag</h3></div>
<label>Inhalt</label>
<textarea
rows="3"
placeholder="z.B.: Mission Control läuft auf Port 9000 — oder: Wir nutzen SQLite statt Qdrant"
style="width:100%;box-sizing:border-box;resize:vertical;margin-bottom:10px"
bind:value={newContent}
onkeydown={e => { if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) addMem() }}
></textarea>
<label>Kategorie</label>
<select bind:value={newCategory} style="margin-bottom:14px">
<option value="stable">Stabil — Projektfakten, die sich selten ändern</option>
<option value="versioned">Versioniert — Tech-Versionen (bei Updates überschreiben)</option>
<option value="ephemeral">Temporär — Aktuelle Aufgabe (nach 7 Tagen gelöscht)</option>
</select>
<div class="btn-row">
<button class="primary" onclick={addMem} disabled={!newContent.trim()}>Speichern</button>
<button class="ghost" onclick={() => { adding = false; newContent = '' }}>Abbrechen</button>
<span class="card-sub" style="font-size:11.5px;margin-left:4px">Strg+Enter zum Speichern</span>
</div>
</div>
{/if}
<!-- Category filter -->
<div class="card" style="padding:10px 16px">
<div class="flex gap-2" style="flex-wrap:wrap;align-items:center">
<span style="font-size:11.5px;color:var(--mut);margin-right:4px">Filter:</span>
{#each [
['all', 'Alle', '', memories.length],
['stable', 'Stabil', 'var(--accent)', countCat('stable')],
['versioned', 'Versioniert', '#f59e0b', countCat('versioned')],
['ephemeral', 'Temporär', 'var(--mut)', countCat('ephemeral')],
] as [val, lbl, clr, cnt]}
<span class="chip"
role="button" tabindex="0"
style="cursor:pointer;{filter === val
? `background:rgba(45,212,191,.12);border-color:${clr || 'var(--accent)'};color:${clr || 'var(--accent)'}`
: ''}"
onclick={() => filter = val}
onkeydown={e => e.key === 'Enter' && (filter = val)}>
{lbl} <span style="color:var(--mut);margin-left:2px">{cnt}</span>
</span>
{/each}
</div>
</div>
<!-- Memory list -->
{#if loading}
<div class="card"><div class="card-sub">Lade…</div></div>
{:else if filtered.length === 0}
<div class="card">
<div class="empty-c" style="padding:8px 0 4px">
<div class="e-t">{memories.length === 0 ? 'Noch keine Einträge' : 'Keine Treffer'}</div>
<div class="e-s">
{memories.length === 0
? 'Klicke „+ Eintrag" um manuell etwas zu speichern, oder verbinde ein KI-Tool via MCP.'
: 'Andere Kategorie wählen oder Eintrag anlegen.'}
</div>
{#if memories.length === 0}
<button class="primary" style="margin-top:14px" onclick={() => adding = true}>
+ Ersten Eintrag anlegen
</button>
{/if}
</div>
</div>
{:else}
<div class="card" style="padding:0;overflow:hidden">
{#each filtered as m, i (m.id)}
<div style="padding:14px 16px;{i < filtered.length - 1 ? 'border-bottom:1px solid var(--brd, rgba(255,255,255,.07))' : ''}">
{#if editId === m.id}
<textarea
rows="3"
style="width:100%;box-sizing:border-box;resize:vertical;margin-bottom:8px"
bind:value={editContent}
></textarea>
<select bind:value={editCategory} style="margin-bottom:10px">
<option value="stable">Stabil</option>
<option value="versioned">Versioniert</option>
<option value="ephemeral">Temporär</option>
</select>
<div class="btn-row">
<button class="primary" onclick={() => saveMem(m)}>Speichern</button>
<button class="ghost" onclick={() => editId = null}>Abbrechen</button>
</div>
{:else}
<div style="display:flex;gap:10px;align-items:flex-start">
<div style="flex:1;min-width:0">
<div style="font-size:13.5px;line-height:1.55;word-break:break-word">{m.content}</div>
<div class="flex gap-2" style="margin-top:8px;flex-wrap:wrap;align-items:center">
<span class="chip" style="border-color:{CAT[m.category]?.color || 'var(--mut)'};color:{CAT[m.category]?.color || 'var(--mut)'}">
{CAT[m.category]?.label || m.category}
</span>
<span class="chip">{m.source}</span>
<span style="font-size:11px;color:var(--mut)">{relTime(m.updated_at)}</span>
</div>
</div>
<div class="flex gap-2" style="flex-shrink:0;margin-top:2px">
<button class="ghost" style="padding:4px 10px;font-size:12px"
onclick={() => startEdit(m)}>Bearbeiten</button>
<button class="ghost" style="padding:4px 10px;font-size:12px;color:var(--err)"
onclick={() => delMem(m)}>Löschen</button>
</div>
</div>
{/if}
</div>
{/each}
</div>
{/if}
<!-- Info-Box: wie KI-Tools das Gedächtnis nutzen -->
<div class="card" style="background:rgba(45,212,191,.04);border-color:rgba(45,212,191,.2)">
<div class="card-h"><h3 style="font-size:13px;font-weight:500">Wie KI-Tools dieses Gedächtnis nutzen</h3></div>
<div class="card-sub" style="line-height:1.6">
Cline, OpenCode und Claude Code greifen über den <b>Memory MCP-Server</b> auf diese Einträge zu.
Beim Start einer Session lädt der Agent automatisch alle Einträge — er kennt dann deinen Stack,
deine Präferenzen und frühere Entscheidungen. Den Setup-Guide findest du unter
<a href="#"
onclick={e => { e.preventDefault(); (document.querySelector(".nav-item[data-view='connect']") as HTMLElement)?.click() }}
style="color:var(--accent)">Verbinden → Gedächtnis-MCP</a>.
</div>
<div class="card-sub" style="margin-top:8px;font-size:11.5px">
Kategorien: <b style="color:var(--accent)">Stabil</b> = Projektfakten die sich selten ändern ·
<b style="color:#f59e0b">Versioniert</b> = Tech-Versionen (bei Updates überschreiben) ·
<b style="color:var(--mut)">Temporär</b> = aktuelle Aufgaben, nach 7 Tagen automatisch gelöscht
</div>
</div>