feat(brain): memory_save Tool — ARIA schreibt selber in die Qdrant-DB
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) <noreply@anthropic.com>
This commit is contained in:
@@ -87,6 +87,14 @@ interface ChatMessage {
|
|||||||
fires_at?: string;
|
fires_at?: string;
|
||||||
condition?: 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
|
/** Backup-Timestamp aus chat_backup.jsonl auf dem Bridge — Voraussetzung
|
||||||
* zum Loeschen der Bubble via Muelltonne. Lokale Bubbles ohne backupTs
|
* zum Loeschen der Bubble via Muelltonne. Lokale Bubbles ohne backupTs
|
||||||
* sind noch nicht persistiert (kurzer Race) — Muelltonne erscheint erst
|
* sind noch nicht persistiert (kurzer Race) — Muelltonne erscheint erst
|
||||||
@@ -467,6 +475,7 @@ const ChatScreen: React.FC = () => {
|
|||||||
const localOnly = prev.filter(m =>
|
const localOnly = prev.filter(m =>
|
||||||
m.skillCreated ||
|
m.skillCreated ||
|
||||||
m.triggerCreated ||
|
m.triggerCreated ||
|
||||||
|
m.memorySaved ||
|
||||||
(m.audioRequestId && (!m.text || m.text === '🎙 Aufnahme...' || m.text === 'Aufnahme...'))
|
(m.audioRequestId && (!m.text || m.text === '🎙 Aufnahme...' || m.text === 'Aufnahme...'))
|
||||||
);
|
);
|
||||||
// Server-Stand + lokal-only (chronologisch sortiert)
|
// Server-Stand + lokal-only (chronologisch sortiert)
|
||||||
@@ -521,6 +530,27 @@ const ChatScreen: React.FC = () => {
|
|||||||
return;
|
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
|
// file_deleted: Datei wurde geloescht (vom Diagnostic User) → Bubble updaten
|
||||||
if (message.type === 'file_deleted') {
|
if (message.type === 'file_deleted') {
|
||||||
const p = (message.payload?.path as string) || '';
|
const p = (message.payload?.path as string) || '';
|
||||||
@@ -1253,6 +1283,27 @@ const ChatScreen: React.FC = () => {
|
|||||||
? { borderWidth: 2, borderColor: '#FFD60A' }
|
? { borderWidth: 2, borderColor: '#FFD60A' }
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
// Spezial-Bubble: ARIA hat etwas via memory_save gespeichert
|
||||||
|
if (item.memorySaved) {
|
||||||
|
const m = item.memorySaved;
|
||||||
|
const catPart = m.category ? ` · [${m.category}]` : '';
|
||||||
|
return (
|
||||||
|
<View style={[styles.messageBubble, styles.ariaBubble, {borderLeftWidth: 3, borderLeftColor: '#FFD60A'}, searchHighlightStyle]}>
|
||||||
|
<Text style={{color: '#FFD60A', fontWeight: 'bold', fontSize: 14}}>
|
||||||
|
{'🧠 ARIA hat etwas gemerkt'}
|
||||||
|
</Text>
|
||||||
|
<Text style={{color: '#E0E0F0', marginTop: 4, fontSize: 14}}>
|
||||||
|
<Text style={{fontWeight: 'bold'}}>{m.title}</Text>
|
||||||
|
<Text style={{color: '#8888AA', fontSize: 12}}>{` (${m.type}${m.pinned ? ' · 📌 pinned' : ''}${catPart})`}</Text>
|
||||||
|
</Text>
|
||||||
|
{m.preview ? (
|
||||||
|
<Text style={{color: '#888', fontSize: 12, marginTop: 4}}>{m.preview}{m.preview.length >= 140 ? '…' : ''}</Text>
|
||||||
|
) : null}
|
||||||
|
<Text style={{color: '#555570', fontSize: 10, marginTop: 6}}>ARIA-Memory · {time}</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Spezial-Bubble: ARIA hat einen Trigger angelegt
|
// Spezial-Bubble: ARIA hat einen Trigger angelegt
|
||||||
if (item.triggerCreated) {
|
if (item.triggerCreated) {
|
||||||
const t = item.triggerCreated;
|
const t = item.triggerCreated;
|
||||||
|
|||||||
@@ -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:
|
else:
|
||||||
lines.append(f"- {t['name']} ({t['type']}, {state})")
|
lines.append(f"- {t['name']} ({t['type']}, {state})")
|
||||||
return "\n".join(lines)
|
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}"
|
return f"Unbekanntes Tool: {name}"
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.exception("Tool '%s' fehlgeschlagen", name)
|
logger.exception("Tool '%s' fehlgeschlagen", name)
|
||||||
|
|||||||
@@ -1376,6 +1376,17 @@ class ARIABridge:
|
|||||||
})
|
})
|
||||||
logger.info("[brain] location_tracking Request: on=%s (%s)",
|
logger.info("[brain] location_tracking Request: on=%s (%s)",
|
||||||
event.get("on"), event.get("reason", ""))
|
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:
|
# _process_core_response uebernimmt alles weitere:
|
||||||
# File-Marker extrahieren + broadcasten, NO_REPLY-Check, Chat-
|
# File-Marker extrahieren + broadcasten, NO_REPLY-Check, Chat-
|
||||||
@@ -2635,6 +2646,12 @@ class ARIABridge:
|
|||||||
},
|
},
|
||||||
"timestamp": int(asyncio.get_event_loop().time() * 1000),
|
"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:
|
except Exception:
|
||||||
logger.exception("[trigger-fire] Side-Channel-Event %s fehlgeschlagen", etype)
|
logger.exception("[trigger-fire] Side-Channel-Event %s fehlgeschlagen", etype)
|
||||||
|
|
||||||
|
|||||||
@@ -1383,6 +1383,14 @@
|
|||||||
}
|
}
|
||||||
return;
|
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_delta') { return; }
|
||||||
if (msg.type === 'chat_error') {
|
if (msg.type === 'chat_error') {
|
||||||
addChat('error', msg.error, '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 ? '<span style="color:#FFD60A;font-size:11px;margin-left:6px;">📌 pinned</span>' : '';
|
||||||
|
const catBadge = cat ? ` <span style="color:#555570;font-size:10px;">[${escapeHtml(cat)}]</span>` : '';
|
||||||
|
const html = `
|
||||||
|
<div style="font-weight:bold;color:#FFD60A;">🧠 ARIA hat etwas gemerkt</div>
|
||||||
|
<div style="margin-top:4px;color:#E0E0F0;">
|
||||||
|
<strong>${escapeHtml(title)}</strong>
|
||||||
|
<span style="color:#8888AA;font-size:11px;margin-left:6px;">(${escapeHtml(typeLabel)})</span>
|
||||||
|
${pinBadge}${catBadge}
|
||||||
|
</div>
|
||||||
|
${preview ? `<div style="color:#8888AA;font-size:12px;margin-top:2px;">${escapeHtml(preview)}${preview.length >= 140 ? '…' : ''}</div>` : ''}
|
||||||
|
<div class="meta">
|
||||||
|
ARIA-Memory — ${new Date().toLocaleTimeString('de-DE')} ·
|
||||||
|
<a href="#" onclick="event.preventDefault();switchMainTab('brain');" style="color:#FFD60A;">im Gehirn-Tab ansehen</a>
|
||||||
|
</div>`;
|
||||||
|
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
|
/** Wenn der Server file_deleted broadcastet: alle Bubbles mit
|
||||||
diesem serverPath rerendern als "geloescht" markieren. */
|
diesem serverPath rerendern als "geloescht" markieren. */
|
||||||
function markFileDeletedInChat(serverPath) {
|
function markFileDeletedInChat(serverPath) {
|
||||||
|
|||||||
@@ -617,6 +617,11 @@ function connectRVS(forcePlain) {
|
|||||||
// Mode-Broadcast von der Bridge → an Browser-Clients weiterreichen
|
// Mode-Broadcast von der Bridge → an Browser-Clients weiterreichen
|
||||||
log("info", "rvs", `Mode-Broadcast: ${msg.payload?.mode} (${msg.payload?.name})`);
|
log("info", "rvs", `Mode-Broadcast: ${msg.payload?.mode} (${msg.payload?.name})`);
|
||||||
broadcast({ type: "mode", payload: msg.payload });
|
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") {
|
} else if (msg.type === "chat_message_deleted") {
|
||||||
// Bridge meldet: Bubble wurde aus chat_backup + Brain entfernt.
|
// Bridge meldet: Bubble wurde aus chat_backup + Brain entfernt.
|
||||||
// An Browser-Clients weiterreichen damit sie die Bubble lokal entfernen.
|
// An Browser-Clients weiterreichen damit sie die Bubble lokal entfernen.
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ const ALLOWED_TYPES = new Set([
|
|||||||
"xtts_import_voice", "xtts_voice_imported",
|
"xtts_import_voice", "xtts_voice_imported",
|
||||||
"skill_created",
|
"skill_created",
|
||||||
"trigger_created",
|
"trigger_created",
|
||||||
|
"memory_saved",
|
||||||
"location_update", "location_tracking",
|
"location_update", "location_tracking",
|
||||||
"chat_history_request", "chat_history_response", "chat_cleared",
|
"chat_history_request", "chat_history_response", "chat_cleared",
|
||||||
"delete_message_request", "chat_message_deleted",
|
"delete_message_request", "chat_message_deleted",
|
||||||
|
|||||||
Reference in New Issue
Block a user