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:
@@ -20,7 +20,7 @@ from fastapi import FastAPI, HTTPException
|
||||
from fastapi.responses import FileResponse, JSONResponse, Response
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from routers import jobs, maintenance, models, system, cookbook, integration, news
|
||||
from routers import jobs, maintenance, memory, models, system, cookbook, integration, news
|
||||
|
||||
app = FastAPI(title="Mission Control")
|
||||
|
||||
@@ -43,6 +43,7 @@ app.include_router(system.router)
|
||||
app.include_router(cookbook.router)
|
||||
app.include_router(integration.router)
|
||||
app.include_router(news.router)
|
||||
app.include_router(memory.router)
|
||||
|
||||
_STATIC = Path(__file__).parent / "static"
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ MODELS_DIR = Path(os.environ.get("MC_MODELS_DIR", "/srv/models"))
|
||||
# Eigene Cookbook-Setups (vom Nutzer angelegt). Bewusst NICHT im App-Verzeichnis, sonst
|
||||
# wuerde ein Deploy (rsync) sie ueberschreiben -> persistent neben den Modellen ablegen.
|
||||
USER_RECIPES_PATH = Path(os.environ.get("MC_USER_RECIPES", str(MODELS_DIR / "mission-control-recipes.json")))
|
||||
MEMORY_DB = Path(os.environ.get("MC_MEMORY_DB", str(MODELS_DIR / "mission-control-memory.db")))
|
||||
# Cache der automatischen Modell-Entdeckung ("aktuell beste Modelle", live von HuggingFace).
|
||||
# Ebenfalls persistent neben den Modellen (uebersteht Deploys). TTL = wie lange der Cache
|
||||
# als frisch gilt, bevor lazy neu von den Quellen geladen wird (Default 12 h).
|
||||
|
||||
@@ -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}"]`)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Mission Control — Memory MCP Server
|
||||
|
||||
Stdio-MCP-Server fuer persistentes Gedaechtnis.
|
||||
Laeuft als Subprocess von Cline / Claude Code / OpenCode auf dem Client-Rechner.
|
||||
Ruft die /api/memory-Endpunkte von Mission Control via HTTP auf.
|
||||
|
||||
Konfiguration via Env-Vars:
|
||||
MC_URL Mission Control URL (default: http://192.168.178.151:9000)
|
||||
MC_TOKEN Auth-Token (default: leer)
|
||||
|
||||
Einmalig installieren (auf dem Rechner wo das KI-Tool laeuft):
|
||||
pip install mcp httpx
|
||||
"""
|
||||
|
||||
import os
|
||||
import httpx
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
|
||||
MC_URL = os.environ.get("MC_URL", "http://192.168.178.151:9000").rstrip("/")
|
||||
MC_TOKEN = os.environ.get("MC_TOKEN", "")
|
||||
|
||||
mcp = FastMCP("mission-control-memory")
|
||||
|
||||
|
||||
def _headers() -> dict:
|
||||
return {"X-MC-Token": MC_TOKEN} if MC_TOKEN else {}
|
||||
|
||||
|
||||
def _get(path: str, **params) -> list | dict:
|
||||
r = httpx.get(f"{MC_URL}{path}", headers=_headers(), params=params, timeout=10)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
|
||||
def _post(path: str, data: dict) -> dict:
|
||||
r = httpx.post(f"{MC_URL}{path}", headers=_headers(), json=data, timeout=10)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
|
||||
def _put(path: str, data: dict) -> dict:
|
||||
r = httpx.put(f"{MC_URL}{path}", headers=_headers(), json=data, timeout=10)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
|
||||
def _delete(path: str) -> dict:
|
||||
r = httpx.delete(f"{MC_URL}{path}", headers=_headers(), timeout=10)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def get_memories(category: str = "") -> str:
|
||||
"""Laedt gespeicherte Fakten und Entscheidungen. category: stable | versioned | ephemeral | (leer = alle)"""
|
||||
params = {"category": category} if category else {}
|
||||
items = _get("/api/memory", **params)
|
||||
if not items:
|
||||
return "Keine Memories gespeichert."
|
||||
cat_icon = {"stable": "🔵", "versioned": "🟡", "ephemeral": "⏱"}
|
||||
return "\n".join(
|
||||
f"{cat_icon.get(m['category'], '·')} [{m['category']}] {m['content']}"
|
||||
f" (Quelle: {m['source']}, ID: {m['id'][:8]})"
|
||||
for m in items
|
||||
)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def add_memory(content: str, category: str = "stable", source: str = "agent") -> str:
|
||||
"""Speichert einen Fakt oder eine Entscheidung.
|
||||
category: stable=Projektfakten (selten aendern), versioned=Tech-Versionen (bei Updates ueberschreiben), ephemeral=temporaerer Kontext (nach 7 Tagen geloescht).
|
||||
"""
|
||||
m = _post("/api/memory", {"content": content, "category": category, "source": source})
|
||||
return f"Gespeichert (ID: {m['id'][:8]}): {content}"
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def search_memories(q: str) -> str:
|
||||
"""Sucht per Stichwort in allen gespeicherten Fakten."""
|
||||
items = _get("/api/memory", q=q)
|
||||
if not items:
|
||||
return f"Keine Treffer fuer '{q}'."
|
||||
return "\n".join(
|
||||
f"[{m['category']}] {m['content']} (ID: {m['id'][:8]})"
|
||||
for m in items
|
||||
)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def update_memory(memory_id: str, content: str = "", category: str = "") -> str:
|
||||
"""Aktualisiert einen bestehenden Eintrag (z.B. nach Tech-Update). Nur gesetzte Felder werden geaendert."""
|
||||
data: dict = {}
|
||||
if content:
|
||||
data["content"] = content
|
||||
if category:
|
||||
data["category"] = category
|
||||
if not data:
|
||||
return "Nichts zu aktualisieren (content und category sind leer)."
|
||||
m = _put(f"/api/memory/{memory_id}", data)
|
||||
return f"Aktualisiert: {m['content']}"
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def delete_memory(memory_id: str) -> str:
|
||||
"""Loescht einen veralteten oder falschen Eintrag anhand seiner ID."""
|
||||
_delete(f"/api/memory/{memory_id}")
|
||||
return f"Eintrag {memory_id[:8]} geloescht."
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
mcp.run()
|
||||
@@ -4,3 +4,4 @@ httpx>=0.27
|
||||
ruamel.yaml>=0.18
|
||||
psutil>=5.9.0
|
||||
huggingface_hub>=0.34 # liefert die `hf`-CLI fuer Modell-Downloads
|
||||
mcp>=1.0 # MCP Python SDK fuer mcp_memory.py stdio-Server
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
"""
|
||||
Memory Layer fuer Mission Control.
|
||||
|
||||
Persistentes Gedaechtnis fuer lokale LLM-Agenten (Cline, Claude Code, OpenCode).
|
||||
SQLite aus stdlib — kein Vektor-Overhead, keine neue Abhaengigkeit.
|
||||
Alle MCP-Tools teilen denselben Speicher via mcp_memory.py-Wrapper.
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from auth import auth
|
||||
from config import MEMORY_DB
|
||||
|
||||
router = APIRouter(prefix="/api", dependencies=[Depends(auth)])
|
||||
|
||||
_db_conn: Optional[sqlite3.Connection] = None
|
||||
|
||||
|
||||
def _db() -> sqlite3.Connection:
|
||||
global _db_conn
|
||||
if _db_conn is None:
|
||||
MEMORY_DB.parent.mkdir(parents=True, exist_ok=True)
|
||||
_db_conn = sqlite3.connect(str(MEMORY_DB), check_same_thread=False)
|
||||
_db_conn.row_factory = sqlite3.Row
|
||||
_db_conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS memories (
|
||||
id TEXT PRIMARY KEY,
|
||||
content TEXT NOT NULL,
|
||||
category TEXT NOT NULL DEFAULT 'stable',
|
||||
source TEXT NOT NULL DEFAULT 'manual',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)
|
||||
""")
|
||||
# Temporaere Eintraege nach 7 Tagen automatisch loeschen
|
||||
_db_conn.execute(
|
||||
"DELETE FROM memories WHERE category = 'ephemeral'"
|
||||
" AND datetime(created_at) < datetime('now', '-7 days')"
|
||||
)
|
||||
_db_conn.commit()
|
||||
return _db_conn
|
||||
|
||||
|
||||
class _MemIn(BaseModel):
|
||||
content: str
|
||||
category: str = "stable" # stable | versioned | ephemeral
|
||||
source: str = "manual"
|
||||
|
||||
|
||||
class _MemUp(BaseModel):
|
||||
content: Optional[str] = None
|
||||
category: Optional[str] = None
|
||||
|
||||
|
||||
def _row(r: sqlite3.Row) -> dict:
|
||||
return dict(r)
|
||||
|
||||
|
||||
# Export muss vor /{mid} stehen, sonst wuerde FastAPI "export" als ID behandeln
|
||||
@router.get("/memory/export")
|
||||
def export_memories():
|
||||
"""Alle Eintraege als strukturierter Plain-Text — direkt als System-Prompt-Kontext nutzbar."""
|
||||
db = _db()
|
||||
rows = db.execute(
|
||||
"SELECT * FROM memories ORDER BY category, updated_at DESC"
|
||||
).fetchall()
|
||||
cat_names = {
|
||||
"stable": "Stabile Fakten",
|
||||
"versioned": "Versionierte Infos",
|
||||
"ephemeral": "Temporaerer Kontext",
|
||||
}
|
||||
lines = ["# Mission Control — Gedaechtnis\n"]
|
||||
current = ""
|
||||
for r in rows:
|
||||
cat = cat_names.get(r["category"], r["category"])
|
||||
if cat != current:
|
||||
lines.append(f"\n## {cat}\n")
|
||||
current = cat
|
||||
lines.append(
|
||||
f"- {r['content']} _(Quelle: {r['source']}, {r['updated_at'][:10]})_"
|
||||
)
|
||||
return {"text": "\n".join(lines), "count": len(rows)}
|
||||
|
||||
|
||||
@router.get("/memory")
|
||||
def list_memories(q: str = "", category: str = ""):
|
||||
db = _db()
|
||||
sql = "SELECT * FROM memories"
|
||||
params: list = []
|
||||
conds: list = []
|
||||
if q:
|
||||
conds.append("content LIKE ?")
|
||||
params.append(f"%{q}%")
|
||||
if category:
|
||||
conds.append("category = ?")
|
||||
params.append(category)
|
||||
if conds:
|
||||
sql += " WHERE " + " AND ".join(conds)
|
||||
sql += " ORDER BY created_at DESC"
|
||||
return [_row(r) for r in db.execute(sql, params).fetchall()]
|
||||
|
||||
|
||||
@router.post("/memory", status_code=201)
|
||||
def add_memory(body: _MemIn):
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
mid = str(uuid.uuid4())
|
||||
db = _db()
|
||||
db.execute(
|
||||
"INSERT INTO memories (id, content, category, source, created_at, updated_at)"
|
||||
" VALUES (?,?,?,?,?,?)",
|
||||
(mid, body.content.strip(), body.category, body.source, now, now),
|
||||
)
|
||||
db.commit()
|
||||
return {
|
||||
"id": mid, "content": body.content.strip(),
|
||||
"category": body.category, "source": body.source,
|
||||
"created_at": now, "updated_at": now,
|
||||
}
|
||||
|
||||
|
||||
@router.put("/memory/{mid}")
|
||||
def update_memory(mid: str, body: _MemUp):
|
||||
db = _db()
|
||||
row = db.execute("SELECT * FROM memories WHERE id = ?", (mid,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Eintrag nicht gefunden")
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
content = body.content.strip() if body.content is not None else row["content"]
|
||||
category = body.category if body.category is not None else row["category"]
|
||||
db.execute(
|
||||
"UPDATE memories SET content=?, category=?, updated_at=? WHERE id=?",
|
||||
(content, category, now, mid),
|
||||
)
|
||||
db.commit()
|
||||
return {
|
||||
"id": mid, "content": content, "category": category,
|
||||
"source": row["source"], "created_at": row["created_at"], "updated_at": now,
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/memory/{mid}")
|
||||
def delete_memory(mid: str):
|
||||
db = _db()
|
||||
if not db.execute("SELECT id FROM memories WHERE id = ?", (mid,)).fetchone():
|
||||
raise HTTPException(404, "Eintrag nicht gefunden")
|
||||
db.execute("DELETE FROM memories WHERE id = ?", (mid,))
|
||||
db.commit()
|
||||
return {"ok": True}
|
||||
Vendored
+40
-27
File diff suppressed because one or more lines are too long
@@ -22,6 +22,7 @@
|
||||
<a class="nav-item" data-view="connect"><span class="ni-ic" data-ic="swap"></span><span class="ni-tx">Verbinden</span></a>
|
||||
<a class="nav-item" data-view="news"><span class="ni-ic" data-ic="info"></span><span class="ni-tx">News</span></a>
|
||||
<a class="nav-item" data-view="guides"><span class="ni-ic" data-ic="help"></span><span class="ni-tx">Guides</span></a>
|
||||
<a class="nav-item" data-view="memory"><span class="ni-ic" data-ic="database"></span><span class="ni-tx">Gedächtnis</span></a>
|
||||
</nav>
|
||||
<div class="side-foot">
|
||||
<span class="nav-item" id="nav-settings"><span class="ni-ic" data-ic="settings"></span><span class="ni-tx">Einstellungen</span></span>
|
||||
@@ -48,6 +49,7 @@
|
||||
<section class="view" data-view="connect" hidden></section>
|
||||
<section class="view" data-view="news" hidden></section>
|
||||
<section class="view" data-view="guides" hidden></section>
|
||||
<section class="view" data-view="memory" hidden></section>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user