feat(brain): memory_save mit attach_paths — ARIA haengt Bilder selbst an
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 <pfad>` 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) <noreply@anthropic.com>
This commit is contained in:
+58
-4
@@ -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 <pfad>` (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/<id>/ 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}"
|
||||
|
||||
@@ -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_<id>.jpg`.
|
||||
Statt das Bild dort liegen zu lassen (kein direkter Memory-Bezug), kopiert
|
||||
sie es via `memory_save(..., attach_paths=[<src>])` 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)
|
||||
|
||||
Reference in New Issue
Block a user