From 31a1370050fd9c9ac91891130d93a58c87c77413 Mon Sep 17 00:00:00 2001 From: duffyduck Date: Wed, 13 May 2026 02:57:02 +0200 Subject: [PATCH] =?UTF-8?q?feat(brain):=20memory=5Fsave=20mit=20attach=5Fp?= =?UTF-8?q?aths=20=E2=80=94=20ARIA=20haengt=20Bilder=20selbst=20an?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Letzter Baustein vor Stefan's End-to-End-Test: memory_attachments.attach_from_path(memory_id, src_path): - Kopiert eine bestehende Datei aus /shared/uploads/ oder /shared/memory-attachments/ in das Anhang-Verzeichnis der Memory - Pfadschutz: nur ALLOWED_SOURCE_PREFIXES (/shared/uploads/, /shared/memory-attachments/) — kein Zugriff auf Root-FS oder SSH-Keys - Groessen-Limit wie save_attachment (20 MB Default) agent.py memory_save: - Neuer optionaler Parameter `attach_paths: List[str]` - Nach dem upsert: pro Pfad attach_from_path → Payload update mit neuen Anhang-Metadaten - Fehler beim Anhang sind nicht fatal (Memory bleibt gespeichert, Hinweis in der Tool-Response) - Tool-Description deutlich erweitert: expliziter Workflow-Hinweis bei Bildern → erst `Read ` aufrufen (Claude Code Read ist multi-modal), Texte/Kennungen/Marken in den content extrahieren, dann erst memory_save mit attach_paths. Beispiel-Workflow als Pseudocode mit Cessna 172 / Kennung D-EAAA. End-to-End-Workflow ist jetzt einarmig moeglich: User: "Ich hab eine Cessna 172" + Bild im Attachment ARIA: Read /shared/uploads/aria_xy.jpg → sieht "Kennung D-EAAA" ARIA: memory_save(content="Stefan besitzt eine Cessna 172, Kennung D-EAAA, weiss/rot lackiert.", attach_paths=["/shared/uploads/aria_xy.jpg"]) → 🧠-Bubble mit Anhang in der App → Spaetere Frage "welche Kennung hat mein Flieger?" liefert via Cold-Memory den Eintrag inkl. Kennung aus dem content Co-Authored-By: Claude Opus 4.7 (1M context) --- aria-brain/agent.py | 62 +++++++++++++++++++++++++++++--- aria-brain/memory_attachments.py | 35 ++++++++++++++++++ 2 files changed, 93 insertions(+), 4 deletions(-) diff --git a/aria-brain/agent.py b/aria-brain/agent.py index afbab97..3c076ce 100644 --- a/aria-brain/agent.py +++ b/aria-brain/agent.py @@ -225,13 +225,31 @@ META_TOOLS = [ "Termine, Personen). 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." + "Wenn unsicher: type=fact, pinned=false.\n\n" + "### Anhaenge\n" + "`attach_paths` haengt Dateien (Bilder, PDFs, ...) aus `/shared/uploads/` " + "an die Memory. Pfade kommen typischerweise aus dem Chat (Stefan haengt " + "ein Foto an, du siehst den Pfad in der User-Message).\n\n" + "**WICHTIG vor dem Speichern bei Bildern**: Schau dir das Bild ZUERST " + "an mit `Read ` (dein Read-Tool ist multi-modal — es liest Bilder " + "wie Vision-API). Extrahiere alles Relevante in den content: sichtbare " + "Texte, Marken/Modelle, Kennzeichen/Seriennummern, Personen, Orte, " + "auffaellige Details. Dann erst memory_save mit dem extrahierten " + "content + attach_paths fuer das Bild. So weisst du beim spaeteren " + "Cold-Memory-Lookup was im Bild war, ohne es nochmal lesen zu muessen.\n\n" + "Beispiel-Workflow:\n" + "1. User: 'Ich hab eine Cessna 172' + /shared/uploads/aria_xy.jpg\n" + "2. Du: `Read /shared/uploads/aria_xy.jpg` → siehst Foto, erkennst Kennung D-EAAA\n" + "3. Du: `memory_save(type='fact', title='Stefans Cessna 172', " + "content='Stefan besitzt eine Cessna 172, Kennung D-EAAA, " + "weiss/rot lackiert, vor Hangar fotografiert.', " + "attach_paths=['/shared/uploads/aria_xy.jpg'])`" ), "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"}, + "content": {"type": "string", "description": "Der eigentliche Inhalt — wird embedded fuer Semantic Search. Bei Bildern: extrahierte Infos REINSCHREIBEN (Texte, Kennungen, Marken, etc.)"}, "type": { "type": "string", "enum": ["identity", "rule", "preference", "tool", "skill", "fact", "conversation", "reminder"], @@ -240,6 +258,11 @@ META_TOOLS = [ "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)."}, + "attach_paths": { + "type": "array", + "items": {"type": "string"}, + "description": "Optional. Pfade unter /shared/uploads/ die als Anhang an die Memory wandern. Files werden serverseitig nach /shared/memory-attachments// kopiert — Originale bleiben.", + }, }, "required": ["title", "content", "type"], }, @@ -531,6 +554,8 @@ class Agent: 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)) + attach_paths_in = arguments.get("attach_paths") or [] + attach_paths = [str(p).strip() for p in attach_paths_in if str(p).strip()] if isinstance(attach_paths_in, list) else [] try: from memory import MemoryPoint vec = self.embedder.embed(content) @@ -539,6 +564,30 @@ class Agent: pinned=pinned, category=category, source="aria", tags=tags, ) pid = self.store.upsert(point, vec) + # Anhaenge kopieren + Payload updaten + attach_errors: list[str] = [] + if attach_paths: + import memory_attachments as mem_att + new_atts = [] + for src in attach_paths: + try: + meta = mem_att.attach_from_path(pid, src) + new_atts.append(meta) + except ValueError as e: + attach_errors.append(f"{src}: {e}") + if new_atts: + from qdrant_client.http import models as qm + from memory.vector_store import COLLECTION + import datetime as _dt + now = _dt.datetime.now(_dt.timezone.utc).isoformat() + current = self.store.get(pid) + current.attachments = (current.attachments or []) + new_atts + current.updated_at = now + self.store.client.set_payload( + collection_name=COLLECTION, + payload=current.to_payload() | {"updated_at": now}, + points=[pid], + ) saved = self.store.get(pid) self._pending_events.append({ "type": "memory_saved", @@ -549,8 +598,13 @@ class Agent: "attachments": saved.attachments or [], }, }) - return (f"OK — Memory '{title}' gespeichert " - f"(type={mem_type}, pinned={pinned}, id={saved.id[:8]}).") + n_att = len(saved.attachments or []) + msg = (f"OK — Memory '{title}' gespeichert " + f"(type={mem_type}, pinned={pinned}, id={saved.id[:8]}" + + (f", {n_att} Anhang/Anhaenge" if n_att else "") + ").") + if attach_errors: + msg += "\nHinweis: nicht alle Anhaenge konnten kopiert werden:\n - " + "\n - ".join(attach_errors) + return msg except Exception as e: logger.exception("memory_save fehlgeschlagen") return f"FEHLER beim Speichern: {e}" diff --git a/aria-brain/memory_attachments.py b/aria-brain/memory_attachments.py index 164956c..071e382 100644 --- a/aria-brain/memory_attachments.py +++ b/aria-brain/memory_attachments.py @@ -135,3 +135,38 @@ def read_bytes(memory_id: str, filename: str) -> Optional[bytes]: if not target.is_file(): return None return target.read_bytes() + + +# /shared/ ist der einzig akzeptable Source-Pfad fuer attach_from_path — +# ARIA bekommt Files vom User immer in /shared/uploads, eigene Files +# generiert sie in /shared/uploads/ als File-Marker. Kein Zugriff auf +# /root, /etc, /tmp, ssh-Keys, etc. +ALLOWED_SOURCE_PREFIXES = ("/shared/uploads/", "/shared/memory-attachments/") + + +def attach_from_path(memory_id: str, source_path: str) -> dict: + """Kopiert eine existierende Datei aus /shared/* in das Anhang-Verzeichnis + des Memories und gibt die neue Metadaten zurueck. + + Verwendung: ARIA bekommt z.B. ein User-Bild als `/shared/uploads/aria_.jpg`. + Statt das Bild dort liegen zu lassen (kein direkter Memory-Bezug), kopiert + sie es via `memory_save(..., attach_paths=[])` ins Memory-Verzeichnis. + + Pfadschutz: source_path MUSS unter /shared/ liegen — kein Zugriff auf + Root-FS, SSH-Keys etc. + """ + if not memory_id: + raise ValueError("memory_id ist Pflicht") + if not source_path or not isinstance(source_path, str): + raise ValueError("source_path leer") + if not any(source_path.startswith(p) for p in ALLOWED_SOURCE_PREFIXES): + raise ValueError(f"source_path muss unter {' oder '.join(ALLOWED_SOURCE_PREFIXES)} liegen") + src = Path(source_path) + if not src.is_file(): + raise ValueError(f"Datei nicht gefunden: {source_path}") + size = src.stat().st_size + if size > MAX_BYTES: + raise ValueError(f"Datei zu gross ({size} > {MAX_BYTES} Byte)") + # Reuse save_attachment damit Filename-Sanitization + Logging konsistent + data = src.read_bytes() + return save_attachment(memory_id, src.name, data)