From 5f96ace469aa28e23f3fac481440f8396850fc3a Mon Sep 17 00:00:00 2001 From: duffyduck Date: Wed, 13 May 2026 01:27:20 +0200 Subject: [PATCH] =?UTF-8?q?feat(brain):=20memory=5Fsave=20Tool=20=E2=80=94?= =?UTF-8?q?=20ARIA=20schreibt=20selber=20in=20die=20Qdrant-DB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ARIA hatte bisher KEIN Tool um eigene Notizen sauber zu persistieren — sie ist deshalb aufs Claude-Code-File-Memory ausgewichen (das wir mit dem letzten Commit per tmpfs abgeklemmt haben). Jetzt schliesst sich der Loop: ein echtes memory_save-Tool gegen die Qdrant-DB. Brain: - agent.py: memory_save als Meta-Tool mit Schema (title, content, type, optional category/tags/pinned). Tool-Description erklaert die Type-Wahl (identity/rule/preference/tool/skill = pinned, fact/conversation/reminder = cold) und sagt explizit: "Du hast KEIN File-Memory mehr, schreibe nicht in ~/.claude/projects/..." - Dispatcher: validiert type-enum, ruft self.embedder.embed + self.store.upsert, pushed memory_saved als _pending_events damit Bridge eine Bubble broadcasten kann. Side-Channel-Pipeline (gleich wie skill_created/trigger_created): - Bridge send_to_core + _handle_trigger_fired: forwarden memory_saved als RVS-Event - rvs/server.js: ALLOWED_TYPES += memory_saved - diagnostic/server.js: relayed memory_saved von RVS an Browser - diagnostic UI: addMemorySavedBubble (gelber Border) + Auto-Refresh des Gehirn-Tabs wenn aktiv - android: ChatMessage.memorySaved-Feld, Listener fuer memory_saved, renderMessage-Spezialbubble, History-Replace-Schutz (lokal-only) Damit ist die Architektur konsistent: "merk dir X" → ARIA ruft memory_save → Eintrag in Qdrant → Diagnostic-Gehirn-Tab zeigt's sofort → bei naechstem Turn liefert Cold Memory (Semantic Search) das Wissen wieder rein. Co-Authored-By: Claude Opus 4.7 (1M context) --- android/src/screens/ChatScreen.tsx | 51 ++++++++++++++++++++ aria-brain/agent.py | 74 ++++++++++++++++++++++++++++++ bridge/aria_bridge.py | 17 +++++++ diagnostic/index.html | 41 +++++++++++++++++ diagnostic/server.js | 5 ++ rvs/server.js | 1 + 6 files changed, 189 insertions(+) diff --git a/android/src/screens/ChatScreen.tsx b/android/src/screens/ChatScreen.tsx index 86c9d47..edc882a 100644 --- a/android/src/screens/ChatScreen.tsx +++ b/android/src/screens/ChatScreen.tsx @@ -87,6 +87,14 @@ interface ChatMessage { fires_at?: string; condition?: string; }; + /** Memory-Saved-Bubble: ARIA hat etwas via memory_save in die Qdrant-DB gepackt */ + memorySaved?: { + title: string; + type: string; + category?: string; + pinned: boolean; + preview?: string; + }; /** Backup-Timestamp aus chat_backup.jsonl auf dem Bridge — Voraussetzung * zum Loeschen der Bubble via Muelltonne. Lokale Bubbles ohne backupTs * sind noch nicht persistiert (kurzer Race) — Muelltonne erscheint erst @@ -467,6 +475,7 @@ const ChatScreen: React.FC = () => { const localOnly = prev.filter(m => m.skillCreated || m.triggerCreated || + m.memorySaved || (m.audioRequestId && (!m.text || m.text === '🎙 Aufnahme...' || m.text === 'Aufnahme...')) ); // Server-Stand + lokal-only (chronologisch sortiert) @@ -521,6 +530,27 @@ const ChatScreen: React.FC = () => { return; } + // memory_saved: ARIA hat etwas via memory_save Tool in die Qdrant-DB + // gepackt — eigene Bubble (gelb wie trigger/skill). + if (message.type === 'memory_saved') { + const p = (message.payload || {}) as any; + const memoryMsg: ChatMessage = { + id: nextId(), + sender: 'aria', + text: '', + timestamp: Date.now(), + memorySaved: { + title: String(p.title || '(ohne Titel)'), + type: String(p.type || 'fact'), + category: p.category ? String(p.category) : undefined, + pinned: !!p.pinned, + preview: p.content_preview ? String(p.content_preview) : undefined, + }, + }; + setMessages(prev => capMessages([...prev, memoryMsg])); + return; + } + // file_deleted: Datei wurde geloescht (vom Diagnostic User) → Bubble updaten if (message.type === 'file_deleted') { const p = (message.payload?.path as string) || ''; @@ -1253,6 +1283,27 @@ const ChatScreen: React.FC = () => { ? { borderWidth: 2, borderColor: '#FFD60A' } : null; + // Spezial-Bubble: ARIA hat etwas via memory_save gespeichert + if (item.memorySaved) { + const m = item.memorySaved; + const catPart = m.category ? ` · [${m.category}]` : ''; + return ( + + + {'🧠 ARIA hat etwas gemerkt'} + + + {m.title} + {` (${m.type}${m.pinned ? ' · 📌 pinned' : ''}${catPart})`} + + {m.preview ? ( + {m.preview}{m.preview.length >= 140 ? '…' : ''} + ) : null} + ARIA-Memory · {time} + + ); + } + // Spezial-Bubble: ARIA hat einen Trigger angelegt if (item.triggerCreated) { const t = item.triggerCreated; diff --git a/aria-brain/agent.py b/aria-brain/agent.py index 45f3232..691cd97 100644 --- a/aria-brain/agent.py +++ b/aria-brain/agent.py @@ -206,6 +206,44 @@ META_TOOLS = [ }, }, }, + { + "type": "function", + "function": { + "name": "memory_save", + "description": ( + "Speichere eine Information dauerhaft in deinem Gedaechtnis (Qdrant-DB). " + "Nutze das wenn Stefan 'merk dir das' sagt oder du selbst etwas Wichtiges " + "festhalten willst. ALTERNATIVEN VERMEIDEN: du hast KEIN persistentes " + "File-Memory mehr — schreibe nicht in `~/.claude/projects/...`, das ist tot.\n\n" + "Type-Wahl:\n" + "- identity: ARIAs Selbstbild / Wesensart (PINNED)\n" + "- rule: harte Regel / Sicherheit / Werte (PINNED)\n" + "- preference: Stefans Vorlieben/Arbeitsweise (PINNED)\n" + "- tool: Tool-Freigaben / Infrastruktur (PINNED)\n" + "- skill: Faehigkeit / Workflow-Anleitung (PINNED)\n" + "- fact: Wissen ueber Stefan/Welt/Sachen — z.B. 'Stefan hat eine Cessna'. " + "Cold Memory, kommt nur via Semantic Search rein. **Default fuer 'merk-dir-das'-Anfragen.**\n" + "- reminder: Termin/Aufgabe. Fuer ARIA-soll-ausloesen lieber trigger_timer.\n\n" + "Wenn unsicher: type=fact, pinned=false." + ), + "parameters": { + "type": "object", + "properties": { + "title": {"type": "string", "description": "Kurzer Titel (max ~80 Zeichen)"}, + "content": {"type": "string", "description": "Der eigentliche Inhalt — wird embedded fuer Semantic Search"}, + "type": { + "type": "string", + "enum": ["identity", "rule", "preference", "tool", "skill", "fact", "conversation", "reminder"], + "description": "Memory-Typ (siehe oben)", + }, + "category": {"type": "string", "description": "Optional, freier Tag z.B. 'meine-sachen', 'kunden', 'persoenlichkeit'"}, + "tags": {"type": "array", "items": {"type": "string"}, "description": "Optionale Tags"}, + "pinned": {"type": "boolean", "description": "Default false. Nur true wenn die Info IMMER im System-Prompt liegen muss (Identitaet/Regeln/Praeferenzen)."}, + }, + "required": ["title", "content", "type"], + }, + }, + }, ] @@ -467,6 +505,42 @@ class Agent: else: lines.append(f"- {t['name']} ({t['type']}, {state})") return "\n".join(lines) + if name == "memory_save": + title = (arguments.get("title") or "").strip() + content = (arguments.get("content") or "").strip() + mem_type = (arguments.get("type") or "fact").strip() + if not title or not content: + return "FEHLER: title und content sind Pflicht." + valid_types = {"identity", "rule", "preference", "tool", + "skill", "fact", "conversation", "reminder"} + if mem_type not in valid_types: + return f"FEHLER: type muss einer von {sorted(valid_types)} sein." + category = (arguments.get("category") or "").strip() + tags_in = arguments.get("tags") or [] + tags = [str(t).strip() for t in tags_in if str(t).strip()] if isinstance(tags_in, list) else [] + pinned = bool(arguments.get("pinned", False)) + try: + from memory import MemoryPoint + vec = self.embedder.embed(content) + point = MemoryPoint( + id="", type=mem_type, title=title, content=content, + pinned=pinned, category=category, source="aria", tags=tags, + ) + pid = self.store.upsert(point, vec) + saved = self.store.get(pid) + self._pending_events.append({ + "type": "memory_saved", + "memory": { + "id": saved.id, "type": saved.type, "title": saved.title, + "content_preview": (saved.content or "")[:140], + "category": saved.category, "pinned": saved.pinned, + }, + }) + return (f"OK — Memory '{title}' gespeichert " + f"(type={mem_type}, pinned={pinned}, id={saved.id[:8]}).") + except Exception as e: + logger.exception("memory_save fehlgeschlagen") + return f"FEHLER beim Speichern: {e}" return f"Unbekanntes Tool: {name}" except Exception as exc: logger.exception("Tool '%s' fehlgeschlagen", name) diff --git a/bridge/aria_bridge.py b/bridge/aria_bridge.py index a81c99a..0e464e9 100644 --- a/bridge/aria_bridge.py +++ b/bridge/aria_bridge.py @@ -1376,6 +1376,17 @@ class ARIABridge: }) logger.info("[brain] location_tracking Request: on=%s (%s)", event.get("on"), event.get("reason", "")) + elif etype == "memory_saved": + # ARIA hat selber etwas in die Vector-DB gespeichert. + # Eigene Bubble in App + Diagnostic (gelb wie skill/trigger). + await self._send_to_rvs({ + "type": "memory_saved", + "payload": event.get("memory", {}), + "timestamp": int(asyncio.get_event_loop().time() * 1000), + }) + logger.info("[brain] ARIA hat eine Memory angelegt: %s (type=%s)", + event.get("memory", {}).get("title"), + event.get("memory", {}).get("type")) # _process_core_response uebernimmt alles weitere: # File-Marker extrahieren + broadcasten, NO_REPLY-Check, Chat- @@ -2635,6 +2646,12 @@ class ARIABridge: }, "timestamp": int(asyncio.get_event_loop().time() * 1000), }) + elif etype == "memory_saved": + await self._send_to_rvs({ + "type": "memory_saved", + "payload": event.get("memory", {}), + "timestamp": int(asyncio.get_event_loop().time() * 1000), + }) except Exception: logger.exception("[trigger-fire] Side-Channel-Event %s fehlgeschlagen", etype) diff --git a/diagnostic/index.html b/diagnostic/index.html index 7809299..5707274 100644 --- a/diagnostic/index.html +++ b/diagnostic/index.html @@ -1383,6 +1383,14 @@ } return; } + if (msg.type === 'memory_saved') { + addMemorySavedBubble(msg.payload || {}); + // Falls Gehirn-Tab offen: refreshen + if (document.getElementById('tab-brain') && document.getElementById('tab-brain').classList.contains('visible')) { + loadBrainMemoryList(); + } + return; + } if (msg.type === 'chat_delta') { return; } if (msg.type === 'chat_error') { addChat('error', msg.error, 'chat:error'); @@ -1976,6 +1984,39 @@ } } + /** ARIA hat eine Memory in die Qdrant-DB gespeichert — als Bubble anzeigen. */ + function addMemorySavedBubble(memory) { + const title = memory.title || '(ohne Titel)'; + const type = memory.type || 'fact'; + const cat = memory.category || ''; + const pinned = !!memory.pinned; + const preview = memory.content_preview || ''; + const typeLabel = (typeof BRAIN_TYPE_LABELS !== 'undefined' && BRAIN_TYPE_LABELS[type]) || type; + const pinBadge = pinned ? '📌 pinned' : ''; + const catBadge = cat ? ` [${escapeHtml(cat)}]` : ''; + const html = ` +
🧠 ARIA hat etwas gemerkt
+
+ ${escapeHtml(title)} + (${escapeHtml(typeLabel)}) + ${pinBadge}${catBadge} +
+ ${preview ? `
${escapeHtml(preview)}${preview.length >= 140 ? '…' : ''}
` : ''} +
+ ARIA-Memory — ${new Date().toLocaleTimeString('de-DE')} · + im Gehirn-Tab ansehen +
`; + for (const box of [chatBox, document.getElementById('chat-box-fs')]) { + if (!box) continue; + const el = document.createElement('div'); + el.className = 'chat-msg received'; + el.style.borderLeft = '3px solid #FFD60A'; + el.innerHTML = html; + box.appendChild(el); + box.scrollTop = box.scrollHeight; + } + } + /** Wenn der Server file_deleted broadcastet: alle Bubbles mit diesem serverPath rerendern als "geloescht" markieren. */ function markFileDeletedInChat(serverPath) { diff --git a/diagnostic/server.js b/diagnostic/server.js index afbcc51..ff15fcb 100644 --- a/diagnostic/server.js +++ b/diagnostic/server.js @@ -617,6 +617,11 @@ function connectRVS(forcePlain) { // Mode-Broadcast von der Bridge → an Browser-Clients weiterreichen log("info", "rvs", `Mode-Broadcast: ${msg.payload?.mode} (${msg.payload?.name})`); broadcast({ type: "mode", payload: msg.payload }); + } else if (msg.type === "memory_saved") { + // ARIA hat selber etwas in die Qdrant-DB gespeichert (via memory_save Tool). + const m = msg.payload || {}; + log("info", "rvs", `ARIA-Memory gespeichert: "${m.title}" (type=${m.type}, pinned=${m.pinned})`); + broadcast({ type: "memory_saved", payload: m }); } else if (msg.type === "chat_message_deleted") { // Bridge meldet: Bubble wurde aus chat_backup + Brain entfernt. // An Browser-Clients weiterreichen damit sie die Bubble lokal entfernen. diff --git a/rvs/server.js b/rvs/server.js index 209bbb6..acab02c 100644 --- a/rvs/server.js +++ b/rvs/server.js @@ -26,6 +26,7 @@ const ALLOWED_TYPES = new Set([ "xtts_import_voice", "xtts_voice_imported", "skill_created", "trigger_created", + "memory_saved", "location_update", "location_tracking", "chat_history_request", "chat_history_response", "chat_cleared", "delete_message_request", "chat_message_deleted",