Compare commits

..

19 Commits

Author SHA1 Message Date
duffyduck 4b3f8cded2 release: bump version to 0.1.2.9 2026-05-13 08:55:28 +02:00
duffyduck 16ebaa652f feat(brain): memory_search + memory_update Tools — ARIA findet Updates aktiv
Bug-Report von Stefan: er hat im Diagnostic den Baujahr-Memory von
1972 auf 1974 geaendert, ARIA wusste das nicht und beharrte auf 1972
(weil ihr letzter Conversation-Turn noch '1972' enthielt). Sie konnte
auch nicht nachpruefen, sagte selbst: "Qdrant kann ich nicht aktiv
durchsuchen".

Fix: zwei neue Meta-Tools im agent.py.

memory_search(query, mode='text'|'semantic', k=5):
- Volltext oder semantic via store.search_text / store.search
- Liefert Liste mit Titel, ID, Content, Anhaengen
- Tool-Description sagt explizit: "Memory ist Truth ueber dem
  Conversation-Window" — wenn beide unterschiedlich sind, gilt
  Memory. Plus Anker-Anwendungsfaelle: 'schau in deinem Gedaechtnis',
  'ich hab das aktualisiert', 'pruef ob's schon was zum Thema gibt'

memory_update(id, title?, content?, category?, tags?, pinned?):
- Patch existierender Memory per ID (aus memory_search oder Cold-Memory)
- Content-Change triggert Re-Embedding fuer Search, sonst nur
  Payload-Update
- Pushed memory_saved-Event analog zu memory_save (App/Diagnostic
  refreshen)
- Tool-Description empfiehlt explizit Update statt neuem Save bei
  Korrekturen/Ergaenzungen — vermeidet Fragmentierung

Damit kann Stefan jetzt sagen "schau in deinem Gedaechtnis" und ARIA
findet den aktualisierten Eintrag. Plus bei spaeteren Korrekturen
("ach nee, 1974") nutzt ARIA memory_update statt memory_save +
hinterlaesst einen sauberen Eintrag.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 03:23:08 +02:00
duffyduck 27c04a2874 docs: README + issue — Memory-Anhaenge mit Vision-Pipeline (Stufen A-E + attach_paths)
issue.md: neuer Block "Memory-Anhaenge mit Vision (Stufe A-E +
attach_paths)" mit den 7 Punkten (Storage-Layer, Backend-Endpoints,
Diagnostic-UI, App-UI, System-Prompt-Integration, Vision via Read-
Tool, attach_paths fuer einarmigen memory_save+attach-Workflow).

README.md: Diagnostic-Gehirn-Tab-Beschreibung um 📎-Anhaenge erweitert,
plus neuer Roadmap-Eintrag "Memory-Anhaenge mit Vision-Pipeline" der
das End-to-End-Erlebnis erklaert (User-Foto → ARIA liest via Read →
extrahiert Kennzeichen/Marken/Texte → speichert als Memory mit Foto-
Anhang → spaetere Detail-Fragen lassen ARIA das Bild nochmal lesen).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 03:16:20 +02:00
duffyduck 31a1370050 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>
2026-05-13 02:57:02 +02:00
duffyduck 933dd50367 feat(brain): Stufe E — ARIA sieht Bilder ueber Claude Codes Read-Tool
Wir mussten den Proxy nicht patchen. Claude Code's eingebautes
Read-Tool ist multi-modal-faehig — uebergibt man eine Bilddatei,
geht die durch das gleiche Vision-Modell wie via Anthropic-Vision-API.
ARIA hat eh "Tool-Freigaben — Vollzugriff" pinned (inkl. Read), also
muss sie nur wissen dass sie das nutzen darf.

prompts._attachments_line erweitert: bei image/* im Anhang haengen
wir den Hinweis an "Bilder kannst du via `Read <pfad>` direkt ansehen".
ARIA ruft dann selbststaendig Read mit dem Memory-Anhang-Pfad, sieht
das Bild und kann antworten was drauf ist.

Heisst: Stefan sagt "schau dir mein Cessna-Foto an" → ARIA findet
Memory via Cold-Search → sieht die Read-Anweisung → ruft Read auf →
Vision-Modell beschreibt das Bild → ARIA antwortet im Chat.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 02:49:09 +02:00
duffyduck d5531521fa feat(memory): Anhaenge in App-Bubble + System-Prompt (Stufe C + D)
Stufe C — App:
- ChatMessage.memorySaved.attachments [{name, mime, size, path, localUri}]
- memory_saved-Listener uebernimmt payload.attachments
- renderMessage memorySaved-Bubble zeigt Anhaenge als Tap-Reihen
  (Icon 🖼/📄 + Filename + Hint). Tap → file_request via Bridge,
  beim ersten Mal "(tippen zum Laden)" → nach file_response cached
  + bei Bildern setFullscreenImage, bei anderen openFileWithIntent
- file_response-Handler updated zusaetzlich memorySaved.attachments
  per serverPath-Match
- Styles fuer memoryAttachmentRow/Icon/Name/Meta

Stufe D — System-Prompt:
- prompts._attachments_line: pro Memory eine Zeile
  "📎 Anhaenge: foo.jpg (image/jpeg, 109 KB) — Pfad: /shared/memory-attachments/<id>/"
- Wird in build_hot_memory_section + build_cold_memory_section
  nach dem Content angehangen
- ARIA "weiss" damit dass Anhaenge da sind und kann via Bash darauf
  zugreifen (file, head, base64 …). Echt sehen kann sie sie erst mit
  Multi-Modal-Pipeline (Stufe E)
- memory_save Dispatcher: attachments-Liste auch im memory_saved-Event
  (vermutlich [] beim Save, aber konsistent fuer spaeteres
  Speichern-mit-Anhaengen-Pattern)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 02:45:51 +02:00
duffyduck de9b7b46f9 feat(diag): Memory-Anhaenge in der UI (Stufe B)
Diagnostic-Gehirn-Tab kann jetzt Bilder/Dateien an Memory-Eintraege
haengen — drag+drop ueber den File-Input im Memory-Modal.

Memory-Modal (Edit-Modus):
- Neuer Block "📎 Anhaenge" unter Pinned-Checkbox, nur sichtbar wenn
  Memory eine ID hat (Edit). Bei "Neue Memory" stattdessen Hinweis
  "Anhaenge nach Speichern hinzufuegbar".
- "⬆ Datei waehlen" oeffnet File-Picker (multiple), Upload via
  multipart/form-data POST an /memory/{id}/attachments/upload.
- Liste zeigt pro Anhang: Thumbnail (Bilder) oder 📄-Icon,
  Filename, Mime + Groesse, 🗑 Loeschen-Button.
- Bild-Thumbnails sind klickbar → openLightbox.
- Status-Zeile zeigt Upload-Progress + Erfolgsmeldung.

Memory-Liste:
- 📎N-Badge erscheint hinter dem Titel wenn N > 0 Anhaenge da sind.

Diagnostic-Server:
- Brain-Reverse-Proxy-Timeout dynamisch: 120s fuer /attachments-Routen
  (Upload), 60s sonst (vorher pauschal 30s — zu wenig fuer chat/distill).
- multipart-Body wird ueber req.pipe(proxyReq) durchgereicht (FastAPI
  liest File via UploadFile, Content-Type-Header bleibt erhalten).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 02:36:51 +02:00
duffyduck da4e970a31 feat(brain): Memory-Anhaenge — multipart/form-data Endpoint daneben Base64
Stefan's Test scheiterte: ein normales Handy-Foto als Base64 in der
curl-d-Argumentliste sprengt Bash's ARG_MAX (typisch 128KB-2MB). Plus:
Browser-FormData und curl -F sind eh der Standard fuer File-Uploads.

Fix: zusaetzlicher Endpoint
  POST /memory/{id}/attachments/upload  (multipart/form-data, field: file)

Beispiel auf der VM:
  curl -F file=@/pfad/zu/foto.jpg \
       "$ARIA_BRAIN_URL/memory/<id>/attachments/upload" | jq

Base64-Endpoint (/memory/{id}/attachments) bleibt fuer kleine
Uploads + interne JSON-Tools. Beide rufen am Ende den gleichen
_commit_attachment_meta-Helper, der das Memory-Payload um den
neuen Anhang updated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 02:32:24 +02:00
duffyduck c677cfed24 feat(brain): Anhaenge an Memory-Eintraege (Stufe A — Backend)
Pro Memory koennen jetzt Dateien (Bilder, PDFs, Sound, ...) angehaengt
werden. Use-Case: Stefan sagt "ich hab eine Cessna 172" und pinnt
gleich ein Foto dran — ARIA sieht spaeter neben dem Memory auch die
visuelle Referenz (Stufe E = Multi-Modal-Pipeline).

Stufe A baut nur den Backend-Layer; UI kommt in Stufe B (Diagnostic)
und C (App). Anhaenge werden in Stufe A nur via HTTP-API gepflegt
(curl), ARIA selbst kann sie noch nicht hochladen — sinnvoll erst
wenn die Vision-Pipeline (Stufe E) steht.

Komponenten:

- memory_attachments.py: neuer Storage-Helper. Layout
  /shared/memory-attachments/<memory-id>/<safe-filename>.
  Filename-Sanitization (kein Path-Traversal), Limit 20 MB
  konfigurierbar, save/list/delete/read_bytes + delete_all fuer
  Cleanup beim Memory-Delete.

- vector_store.py: MemoryPoint.attachments (List[dict]) — Metadaten
  {name, mime, size, path} im Qdrant-Payload damit Suche/Anzeige
  sie ohne Filesystem-Lookup kennt.

- main.py:
  - MemoryIn akzeptiert attachments-Liste (fuer Restore-Faelle)
  - MemoryOut liefert attachments
  - GET    /memory/{id}/attachments              → Liste vom FS
  - POST   /memory/{id}/attachments              → Base64-Upload,
            schreibt FS + updated Payload-Liste
  - DELETE /memory/{id}/attachments/{filename}   → FS + Payload-Eintrag weg
  - GET    /memory/{id}/attachments/{filename}   → Bytes mit MIME serve
  - /memory/delete cleanup: ruft attachments.delete_all damit kein
    Verzeichnis verwaist

Smoke-Test nach Brain-Rebuild (Stefan auf VM):
  # Memory-ID rauspicken
  ID=$(curl -s "$ARIA_BRAIN_URL/memory/list?type=fact" | python3 -c "import sys,json;print(json.load(sys.stdin)[0]['id'])")
  # Bild als Base64 hochladen
  B64=$(base64 -w0 /pfad/zu/foto.jpg)
  curl -s -X POST "$ARIA_BRAIN_URL/memory/$ID/attachments" \
    -H 'Content-Type: application/json' \
    -d "{\"name\":\"foto.jpg\",\"data_base64\":\"$B64\"}" | jq
  # Liste anzeigen
  curl -s "$ARIA_BRAIN_URL/memory/$ID/attachments" | jq
  # Datei wieder laden
  curl -s "$ARIA_BRAIN_URL/memory/$ID/attachments/foto.jpg" -o /tmp/back.jpg

Stufe B (Diagnostic-UI) folgt sobald A getestet ist.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 02:27:02 +02:00
duffyduck 331c1437be docs: README + issue — DB-Truth-Block + memory_save + Search-Modi + Muelltonne dokumentiert
Was alles seit dem letzten Doc-Update dazukam:

issue.md (Bugfixes):
- Cold Memory Crosstalk durch Score-Threshold
- Pinned-/Type-Filter bei aktiver Suche
- Memory-Liste refresh nach Delete
- Thinking-Indikator im RVS-Chat wieder sichtbar
- Memory-Suche filtert Rauschen (score_threshold am Endpoint)
- Cessna-Phantom-Wissen aus System-Prompt raus
- Claude-Code-Auto-Memory abgeklemmt (tmpfs)

issue.md (Features):
- Neuer Block "Memory-System (Phase B Punkt 5+ Bonus)" mit
  memory_save Tool, Volltext-Suche, Advanced Search, Muelltonne,
  Druckansicht, klappbare Kategorien
- Neuer Block "DB als Single Source of Truth" mit brain-import als
  Drop-Folder, DB-Cleanup 60→31, .claude/aria-vm.env Setup

README.md:
- aria-data/brain-import Tabelle-Beschreibung aktualisiert
- .claude/aria-vm.env als neue Zeile in der Konfig-Tabelle
- Diagnostic Gehirn-Tab Beschreibung ausgebaut (Wortlich/Semantisch,
  Advanced Search, klappbare Kategorien, Druckansicht)
- App-Features: Muelltonne pro Bubble erklaert
- Roadmap-Eintrag "Single Source of Truth — Qdrant" als zentrales
  Abschluss-Item nach Tool-Use-Patch

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 02:23:09 +02:00
duffyduck 1e754910ee fix(brain): Cold Memory mit Score-Threshold — kein Crosstalk mehr
Bug: Agent.chat() rief store.search() OHNE score_threshold — die
Top-5 wurden ungefiltert in den 'Moeglicherweise relevant'-Block
des System-Prompts gepackt. Bei kleiner DB hatte das absurde Folgen:
Stefan fragte 'hab ich ein flugzeug?', Cold-Search lieferte Top-1
'Watcher-Latenzproblem' mit Score 0.138 + 'Firmenadresse' mit 0.094,
ARIA wob die Firmenadresse in die Antwort ein ('Die Adresse habe ich
aus meinem Gedaechtnis...') — obwohl der User gar nicht danach gefragt
hat.

Fix: Konstante COLD_SCORE_THRESHOLD=0.30 in Agent eingefuehrt und an
store.search() durchgereicht. Treffer unter 0.30 werden als Rauschen
verworfen, ARIA bekommt nur substantielle Memories ins Cold-Set.
Konsistent mit dem Threshold im /memory/search HTTP-Endpoint und dem
Diagnostic UI.

MiniLM-multilingual gibt fuer unverwandte deutsche Texte gerne 0.10-
0.25 Score — alles darunter ist Embedder-Noise, kein echter Bezug.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 02:13:32 +02:00
duffyduck 351c58e88e fix(diag): zwei Bugs — Memory-Liste nach Delete + Thinking-Indikator im RVS-Chat
Bug 1: Memory loeschen + Liste zeigt geloeschten Eintrag weiter
  deleteMemory rief loadBrainMemoryList — die fiel bei aktiver Such-
  Ansicht in den Cache-Pfad und renderte den geloeschten Eintrag aus
  brainMemoryCache/brainSearchIds wieder. Fix: nach Delete den Cache-
  Eintrag + brainSearchIds bereinigen und bei aktiver Suche re-search
  ausfuehren (single oder advanced), sonst Vollliste vom Server.

Bug 2: "ARIA denkt..."-Indikator erscheint nicht mehr im Chat-Fenster
  Diagnostic-Server hatte fuer RVS-eingehende agent_activity-Events
  keinen Relay an die Browser-Clients. Bridge sendet die Events brav,
  Diagnostic schluckt sie still. Fix: agent_activity vom RVS an
  Browser broadcasten (mit dem gleichen settled-window-Schutz wie
  beim alten Gateway-Pfad — Trailing-Events nach chat:final werden
  weiter ignoriert).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 01:55:08 +02:00
duffyduck df60bb6d74 fix(brain): Cessna-Beispiel aus System-Prompt raus — keine Phantom-Wissens-Hinweise
ARIA hatte beim 'weisst du ob ich ein Flugzeug habe?'-Test richtig
geantwortet ('nein'), aber transparent erklaert dass sie das Wort
'Cessna' aus dem memory_save Tool-Description kennt — wo es als
Beispiel fuer den fact-Type stand. Ein Beispiel-Text der jedes
Chat-Turn im System-Prompt landet ist suboptimal, auch wenn ARIA
ihn korrekt einordnet.

Fix: das konkrete Beispiel durch eine generische Aufzaehlung
ersetzt (Vorlieben/Besitz/Orte/Termine/Personen). Ohne Stefan-
spezifisches Phantom-Wissen. Selber Spirit in der search-text
Docstring im main.py (geht zwar nicht in den Prompt, aber lieber
konsistent).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 01:39:54 +02:00
duffyduck 24cf40293a fix(diag): Pinned-/Type-Filter wirkt jetzt auch bei aktiver Suche
Bug: runBrainSearch und runAdvancedSearch ignorierten den
brain-filter-pinned Dropdown — egal ob "Nur Pinned" oder "Nur Cold"
gewaehlt war, kam immer alles was die Such-Kriterien erfuellte.
Plus: Dropdown-onchange rief loadBrainMemoryList und brach damit
die Suche ab statt sie mit dem neuen Filter neu auszufuehren.

Fix:
- Neue Helfer brainSearchActive() (erkennt single/advanced/none) und
  applyPinnedFilter() (client-side Filter nach 'all'/'pinned'/'cold').
- runBrainSearch + runAdvancedSearch wenden applyPinnedFilter nach
  dem Backend-Hit an. Info-Box zeigt zusaetzlich an wenn
  Pinned-Filter aktiv war ("... · 📌 nur pinned"), bei 0 Treffern
  auch der unfiltered Count fuer Debug ("X Treffer ohne Pinned-Filter").
- Type+Pinned-Dropdowns onchange → onBrainFiltersChanged: bei
  aktiver Suche re-search, sonst loadBrainMemoryList.

Backend bleibt unveraendert (include_pinned all-or-none reicht —
Feinheit "nur pinned" macht der Client).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 01:31:17 +02:00
duffyduck 5f96ace469 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>
2026-05-13 01:27:20 +02:00
duffyduck 9dd95709b9 fix(proxy): Claude-Code-Auto-Memory abklemmen — tmpfs ueber ~/.claude/projects
Claude Code CLI (im Proxy-Container) hat ein eingebautes Auto-Memory-
Feature das Markdown-Files in ~/.claude/projects/<project>/memory/
schreibt. Weil das CLI als ARIAs LLM laeuft, hat sie da ueber Wochen
ihre eigene Schatten-Wissensbasis aufgebaut (cessna, persoenlichkeit,
projects) — komplett parallel zu unserer Qdrant-DB. Genau die doppelte
Truth-Source die wir vermeiden wollten.

Fix: tmpfs ueber das projects/-Verzeichnis im Proxy-Container.
Effekt:
- Claude Code sieht beim Spawn ein leeres projects/ — keine Auto-
  Memory-Files werden geladen
- Schreibt sie was rein, landet's nur im Container-RAM
- Beim Container-Recreate ist alles weg
- Stefans persoenlicher ~/.claude/projects/ auf der VM bleibt
  unangetastet (Volume ist immer noch gemountet, nur das Subdir
  wird ueberlagert)

Migration auf der VM (Stefan einmalig):
  rm -rf ~/.claude/projects/-/memory/
  docker compose up -d --force-recreate proxy

Auto-Memory ist damit deaktiviert. Naechster Schritt (5): ARIA bekommt
einen eigenen memory_save Tool damit sie Sachen sauber in Qdrant
ablegen kann statt aufs File-Memory auszuweichen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 01:17:21 +02:00
duffyduck a2dee3164a feat(diag): Advanced Search — dynamisch Felder hinzufuegen mit + Button
Statt fest 3 Felder gibt's jetzt eine erweiterbare Reihen-Liste:
- "+ Feld"-Button fuegt eine Reihe hinzu (UND/ODER + Eingabe)
- ✕-Button pro Reihe (ausser der ersten) entfernt sie
- Erste Reihe ist immer "Start" ohne Operator
- syncAdvancedRowsFromDOM rettet Eingaben vor jedem Re-Render
- runAdvancedSearch iteriert ueber alle Reihen mit Inhalt, leere
  werden ignoriert

Damit ist die Boolean-Suche so lang wie noetig — Stefan kann auch
5-6 Begriffe verknuepfen ohne UI-Hack. Min. 1 Feld bleibt immer
(clearAdvancedSearch reseted auf eine leere Start-Reihe).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 01:13:15 +02:00
duffyduck 01f0ad3a40 feat(diag): Advanced Search mit AND/OR + mehrere Begriffe
Klappbares Panel unter dem Suchbalken — Stefan kann bis zu 3 Begriffe
eingeben und mit AND/OR verknuepfen, links nach rechts ausgewertet.
Backend bleibt simpel: pro Begriff einmal /memory/search-text aufgerufen,
die Treffer-Set-IDs werden client-seitig per AND (intersect) oder OR
(union) kombiniert.

UI:
- "⌃ Erweitert" Button rechts neben ✕ klappt das Panel auf
- 3 Eingabefelder mit 2 Operator-Dropdowns dazwischen (UND/ODER)
- "Suchen"-Button im Panel
- "Felder leeren" reseted
- Leere Felder werden ignoriert — sind nur 2 belegt, gibt's nur 1 Operator
- Typ-Filter aus dem Hauptbalken wird mit angewandt
- Info-Banner zeigt die kombinierte Suchformel zurueck

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 17:32:55 +02:00
duffyduck 6549fcbce8 feat(brain): Volltext-Suche zusaetzlich zu Semantic — Default ist jetzt Wortlich
Stefan wollte ne richtige Suche statt nur "klingt aehnlich". Beide
Modi sind jetzt verfuegbar, Default ist Volltext:

- 📝 Wortlich (Substring, case-insensitive ueber Title + Content +
  Category + Tags) — neuer Endpoint /memory/search-text. Full-Scan
  via Qdrant scroll, k=50. Findet "cessna" exakt im Content. Bei
  kleiner DB (<1000 Eintraege) unkritisch performant.

- 🧠 Semantisch (Embedder + score_threshold 0.30) — bestehender
  /memory/search Endpoint. Findet konzeptuell verwandte Eintraege.

Diagnostic UI: Dropdown neben dem Suchfeld zum Modus-Wechsel.
Info-Banner zeigt klar welcher Modus aktiv ist.

Warum Wortlich Default: bei kleiner DB liefert Semantic gern False
Positives mit Score 0.30-0.45 fuer komplett unverwandte Begriffe
(z.B. "cessna" matched "Tageslog fuehren" mit 0.43). Wortlich ist
deterministisch und vermeidet das Rauschen.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 16:59:31 +02:00
15 changed files with 1364 additions and 33 deletions
+6 -2
View File
@@ -242,7 +242,8 @@ Danach wird der Proxy gepatcht:
| `aria-data/ssh/` | SSH-Key fuer den Zugriff auf aria-wohnung (Brain + Proxy teilen den Key) |
| `aria-data/brain/qdrant/` | Vector-DB-Storage (Bind-Mount, gitignored) |
| `aria-data/brain/data/` | Skills, Embedding-Modell-Cache (Bind-Mount, gitignored) |
| `aria-data/brain-import/` | `AGENT.md`, `USER.md.example`, `TOOLING.md.example` — Quelle fuer den initialen Memory-Import in die Vector-DB |
| `aria-data/brain-import/` | **Drop-Folder** fuer Markdown-Saatgut. Inhalt komplett gitignored ausser `.gitkeep` + `README.md`. Stefan kippt MDs rein wenn er was migrieren will, klickt Diagnostic-„Migration aus brain-import/" — sonst leer. DB ist Truth, brain-import nur Cold-Start-Schleuse |
| `.claude/aria-vm.env` | **Lokal pro Dev-Maschine** — wie erreicht die Workstation die VM (IP/Hostname). Gitignored, `.example` als Vorlage. Wird genutzt fuer direktes `curl` gegen die Brain-API von ausserhalb der VM |
| `aria-data/config/diag-state/` | Diagnostic State (z.B. zuletzt aktive Session) |
### /shared/config/ (im aria-shared Volume)
@@ -316,7 +317,7 @@ Erreichbar unter `http://<VM-IP>:3001`. Teilt das Netzwerk mit der Bridge.
### Tabs
- **Main**: Brain/RVS/Proxy-Status, Chat-Test, "ARIA denkt..."-Indikator, End-to-End-Trace, Container-Logs
- **Gehirn**: Memory-Browser (Vector-DB), Suche + Filter, Edit/Add/Delete, Konversation-Status mit Destillat-Trigger, **Token/Call-Metrics mit Subscription-Quota-Tracking**, Bootstrap & Migration (3 Wiederherstellungs-Wege), Gehirn-Export/Import (tar.gz). Info-Buttons () ueberall mit Modal-Erklaerung.
- **Gehirn**: Memory-Browser (Vector-DB), Suche mit zwei Modi (**📝 Wortlich** = Substring-Match Default + **🧠 Semantisch** mit Score-Threshold), **Advanced Search** (aufklappbares Panel, beliebig viele AND/OR-verknuepfte Felder, + Button fuer mehr Zeilen), Type+Pinned-Filter (greifen auch in der Suche), klappbare Type-Kategorien (Default eingeklappt), Add/Edit/Delete mit Category-Autosuggest, **📎 Anhaenge** pro Memory (Bilder/PDFs/...): Upload + Thumbnail-Vorschau + Lightbox + Lösch-Button, 📎N-Badge in der Liste, automatischer Cleanup beim Memory-Delete. -Info-Modal das erklaert welche Types FEST in den Prompt vs. Cold Memory wandern. **📄 Druckansicht** (Strg+P → PDF). Konversation-Status mit Destillat-Trigger, **Token/Call-Metrics mit Subscription-Quota-Tracking**, Bootstrap & Migration (3 Wiederherstellungs-Wege), Gehirn-Export/Import (tar.gz)
- **Skills**: Liste aller Skills mit Logs pro Run, Activate/Deactivate, Export/Import als tar.gz, "von ARIA"-Badge fuer selbst gebaute
- **Trigger**: passive Aufweck-Quellen. **Timer** (einmalig, ISO-Timestamp oder via `in_seconds` als Server-Berechnung) + **Watcher** (recurring, mit Condition + Throttle). Liste aktiver Trigger + Logs pro Feuer-Event. Modal mit Type-Dropdown, Live-Anzeige aller verfuegbaren Condition-Variablen (`disk_free_gb`, `hour_of_day`, `current_lat/lon`, `last_user_message_ago_sec`, …) und Condition-Funktionen (`near(lat, lon, m)` fuer GPS-Geofencing). Sicherer Condition-Parser via Python `ast` (Whitelist, kein `eval`). Der System-Prompt enthaelt zusaetzlich einen `## Aktuelle Zeit`-Block (UTC + Europa/Berlin) damit ARIA Timer-Zeitpunkte korrekt setzen kann.
- **Dateien**: Browser fuer `/shared/uploads/` mit Multi-Select + "Alle markieren" + Bulk-Download (ZIP bei 2+) + Bulk-Delete. Live-Update der Chat-Bubbles beim Delete.
@@ -355,6 +356,7 @@ Erreichbar unter `http://<VM-IP>:3001`. Teilt das Netzwerk mit der Bridge.
- **Voice-Ready Toast**: Beim Wechsel zeigt die App "Stimme X bereit (X.Ys)" sobald der Preload durch ist
- **Play-Button**: Jede ARIA-Nachricht kann nochmal vorgelesen werden (aus Cache wenn vorhanden, sonst neu rendern)
- **Chat-Suche**: Lupe in der Statusleiste filtert Nachrichten live
- **Mülltonne pro Bubble** (mit Confirm): gezielt eine Nachricht loeschen — geht nicht nur aus der UI weg, sondern auch aus `chat_backup.jsonl`, Brain-Conversation-Window und allen anderen Clients (RVS-Broadcast). Wichtig damit ARIA den Turn auch beim naechsten Prompt nicht mehr im Kontext hat
- **Mehrere Anhaenge**: Bilder + Dateien sammeln, Text hinzufuegen, dann zusammen senden
- **Paste-Support**: Bilder aus Zwischenablage einfuegen (Diagnostic)
- **Anhaenge**: Bridge speichert in Shared Volume, ARIA kann darauf zugreifen, Re-Download ueber RVS
@@ -867,6 +869,8 @@ docker exec aria-brain curl localhost:8080/memory/stats
- [x] **Phase B Punkt 4:** Skills-System (Python-only via local-venv, skill_create als Tool, dynamische run_<skill> Tools, Diagnostic Skills-Tab mit Logs/Toggle/Export/Import, skill_created Live-Notification in App+Diagnostic, harte Schwelle "pip → Skill")
- [x] **Phase B Punkt 5:** Triggers-System (passive Aufweck-Quellen — Timer + Watcher mit safe Condition-Parser, GPS-near(), Diagnostic Trigger-Tab, kontinuierliches GPS-Tracking in der App fuer Use-Cases wie Blitzer-Warner). Inklusive Brain → Bridge HTTP-Push (Port 8090 intern) damit Trigger-Antworten ueber RVS in App + Diagnostic + TTS landen.
- [x] **Proxy Tool-Use durchreichen**: claude-max-api-proxy patcht via eigene Adapter (`proxy-patches/`) den `tools`/`tool_calls`-Roundtrip — Claude Code rief vorher ihre internen Tools (Bash, sleep) statt der ARIA-Brain-Tools (trigger_timer, skill_*, ...). Jetzt funktioniert Tool-Use End-to-End.
- [x] **Single Source of Truth — Qdrant**: `memory_save`-Tool fuer ARIA, Claude-Code-Auto-Memory abgeklemmt (tmpfs ueber `~/.claude/projects` im Proxy-Container), `brain-import/` zum reinen Drop-Folder degradiert, Cold-Memory mit Score-Threshold (0.30) gegen Embedder-Noise/Crosstalk, Diagnostic-Gehirn-UI mit Wortlich-/Semantisch-Suche, Advanced Search (AND/OR mit + Button), Memory-Druckansicht, Muelltonne pro Chat-Bubble. DB ist jetzt durchgaengig die einzige Wissensquelle, kein paralleles File-Memory mehr.
- [x] **Memory-Anhaenge mit Vision-Pipeline**: Pro Memory koennen Bilder/PDFs/beliebige Dateien angehaengt werden (unter `/shared/memory-attachments/<id>/`, max 20 MB). Diagnostic-UI mit Thumbnail-Vorschau + Lightbox, App `memory_saved`-Bubble mit Tap-to-Load via RVS, System-Prompt zeigt Anhang-Pfade. **ARIA sieht Bilder echt** via Claude Code's eingebautes multi-modales `Read`-Tool — kein Proxy-Patch noetig. `memory_save` hat `attach_paths`-Parameter sodass ARIA ein User-Foto im selben Tool-Call lesen, Infos extrahieren (Kennzeichen, Marken, Texte) und als Memory + Anhang persistieren kann. Bilder bleiben am Memory haengen — bei spaeteren Detail-Fragen liest ARIA das Bild einfach nochmal.
- [x] Sprachmodell-Setting wieder funktional (brainModel in runtime.json statt aria-core)
- [x] App-Chat-Sync: kompletter Server-Sync bei Reconnect (Server = Source of Truth) + chat_cleared Live-Update. Lokal-only Bubbles (Skill-Notifications, laufende Voice ohne STT) bleiben erhalten.
- [x] App: Chat-Suche mit Next/Prev Navigation statt Filter
+2 -2
View File
@@ -79,8 +79,8 @@ android {
applicationId "com.ariacockpit"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 10208
versionName "0.1.2.8"
versionCode 10209
versionName "0.1.2.9"
// Fallback fuer Libraries mit Product Flavors
missingDimensionStrategy 'react-native-camera', 'general'
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "aria-cockpit",
"version": "0.1.2.8",
"version": "0.1.2.9",
"private": true,
"scripts": {
"android": "react-native run-android",
+147 -6
View File
@@ -87,6 +87,22 @@ interface ChatMessage {
fires_at?: string;
condition?: string;
};
/** Memory-Saved-Bubble: ARIA hat etwas via memory_save in die Qdrant-DB gepackt */
memorySaved?: {
id?: string;
title: string;
type: string;
category?: string;
pinned: boolean;
preview?: string;
attachments?: Array<{
name: string;
mime?: string;
size?: number;
path?: string; // Server-Pfad /shared/memory-attachments/<id>/<name>
localUri?: string; // Nach file_request gefuelltes file://-URI
}>;
};
/** 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 +483,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 +538,35 @@ 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 atts = Array.isArray(p.attachments) ? p.attachments.map((a: any) => ({
name: String(a?.name || 'datei'),
mime: a?.mime ? String(a.mime) : undefined,
size: typeof a?.size === 'number' ? a.size : undefined,
path: a?.path ? String(a.path) : undefined,
})) : [];
const memoryMsg: ChatMessage = {
id: nextId(),
sender: 'aria',
text: '',
timestamp: Date.now(),
memorySaved: {
id: p.id ? String(p.id) : undefined,
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,
attachments: atts.length ? atts : 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) || '';
@@ -565,16 +611,38 @@ const ChatScreen: React.FC = () => {
if (b64 && reqId) {
const fileName = (message.payload.name as string) || 'download';
persistAttachment(b64, reqId, fileName).then(filePath => {
setMessages(prev => prev.map(m => ({
...m,
attachments: m.attachments?.map(a =>
setMessages(prev => prev.map(m => {
// Hauptattachments updaten (Bilder/Files am User-Send / ARIA-File-Bubble)
const updatedAtts = m.attachments?.map(a =>
a.serverPath === serverPath ? { ...a, uri: filePath } : a
),
})));
);
// Memory-Anhang-Match (Bubble vom memory_saved-Event)
const ms = m.memorySaved;
let updatedMs = ms;
if (ms && Array.isArray(ms.attachments)) {
const hit = ms.attachments.some(a => a.path === serverPath);
if (hit) {
updatedMs = {
...ms,
attachments: ms.attachments.map(a =>
a.path === serverPath ? { ...a, localUri: filePath } : a
),
};
}
}
return { ...m, attachments: updatedAtts, memorySaved: updatedMs };
}));
// Wenn der User dieses File explizit oeffnen wollte → Intent-Picker
// (Bilder werden separat via setFullscreenImage in der memorySaved-
// Bubble geoeffnet, das laeuft nicht ueber autoOpenPaths)
if (serverPath && autoOpenPaths.current.has(serverPath)) {
autoOpenPaths.current.delete(serverPath);
openFileWithIntent(filePath.replace(/^file:\/\//, ''), mimeType);
const isImage = (mimeType || '').startsWith('image/');
if (isImage) {
setFullscreenImage(filePath);
} else {
openFileWithIntent(filePath.replace(/^file:\/\//, ''), mimeType);
}
}
}).catch(() => {});
}
@@ -1253,6 +1321,57 @@ 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}]` : '';
const atts = m.attachments || [];
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}
{atts.map((a, idx) => {
const isImage = (a.mime || '').startsWith('image/');
const icon = isImage ? '🖼️' : '📄';
const sizeStr = a.size ? ` · ${(a.size / 1024).toFixed(0)} KB` : '';
return (
<TouchableOpacity
key={`${item.id}-att-${idx}`}
style={styles.memoryAttachmentRow}
onPress={() => {
if (!a.path) return;
if (a.localUri) {
if (isImage) setFullscreenImage(a.localUri);
else openFileWithIntent(a.localUri.replace(/^file:\/\//, ''), a.mime || '');
} else {
// Datei via Bridge nachladen — file_response hat den
// memorySaved-Match-Path und cached + zeigt direkt
autoOpenPaths.current.add(a.path);
rvs.send('file_request' as any, { serverPath: a.path, requestId: `memAtt_${item.id}_${idx}` });
}
}}
>
<Text style={styles.memoryAttachmentIcon}>{icon}</Text>
<Text style={styles.memoryAttachmentName} numberOfLines={1}>{a.name}</Text>
<Text style={styles.memoryAttachmentMeta}>
{a.localUri ? '(tippen zum oeffnen)' : `(tippen zum Laden${sizeStr})`}
</Text>
</TouchableOpacity>
);
})}
<Text style={{color: '#555570', fontSize: 10, marginTop: 6}}>ARIA-Memory · {time}</Text>
</View>
);
}
// Spezial-Bubble: ARIA hat einen Trigger angelegt
if (item.triggerCreated) {
const t = item.triggerCreated;
@@ -2014,6 +2133,28 @@ const styles = StyleSheet.create({
playButtonText: {
fontSize: 16,
},
memoryAttachmentRow: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor: '#0D0D1A',
borderRadius: 6,
paddingHorizontal: 8,
paddingVertical: 6,
marginTop: 4,
gap: 6,
},
memoryAttachmentIcon: {
fontSize: 16,
},
memoryAttachmentName: {
flex: 1,
color: '#E0E0F0',
fontSize: 12,
},
memoryAttachmentMeta: {
color: '#555570',
fontSize: 10,
},
bubbleTrash: {
position: 'absolute',
top: 4,
+286 -2
View File
@@ -206,6 +206,126 @@ META_TOOLS = [
},
},
},
{
"type": "function",
"function": {
"name": "memory_search",
"description": (
"Durchsuche aktiv dein Gedaechtnis (Qdrant-DB). Nutze das wenn:\n"
"- der User sagt 'schau in deinem Gedaechtnis' / 'ich hab das Memory aktualisiert'\n"
"- du dir bei einer Info aus dem Konversations-Verlauf unsicher bist "
"(z.B. ob das noch der aktuelle Stand ist)\n"
"- du pruefen willst ob's schon einen Memory zu einem Thema gibt bevor "
"du via memory_save einen neuen anlegst (vermeidet Fragmentierung)\n\n"
"**WICHTIG: Memory ist Truth ueber dem Conversation-Window.** "
"Wenn dort was anders steht als in deinem Gespraechs-Verlauf, gilt das "
"was im Memory steht — der User koennte gerade was korrigiert haben.\n\n"
"Mode 'text' = Substring (case-insensitive), gut fuer exakte Begriffe "
"wie 'cessna'. Mode 'semantic' = Embedder-Search, gut fuer 'wann hatten "
"wir ueber X gesprochen'-Fragen."
),
"parameters": {
"type": "object",
"properties": {
"query": {"type": "string", "description": "Such-Begriff"},
"mode": {
"type": "string",
"enum": ["text", "semantic"],
"description": "Default 'text' (Substring). 'semantic' fuer aehnlichkeits-Suche.",
},
"k": {"type": "integer", "description": "Wieviele Treffer (Default 5, max 20)"},
},
"required": ["query"],
},
},
},
{
"type": "function",
"function": {
"name": "memory_update",
"description": (
"Aktualisiere einen existierenden Memory-Eintrag — gibt die ID aus "
"memory_search oder dem Cold-Memory an. Nur die uebergebenen Felder werden "
"ueberschrieben, der Rest bleibt unangetastet. **Bevorzuge das ueber "
"memory_save** wenn der User eine Korrektur macht oder du zusaetzliche "
"Details zum gleichen Thema hast — vermeidet doppelte Eintraege."
),
"parameters": {
"type": "object",
"properties": {
"id": {"type": "string", "description": "Memory-ID (UUID, aus memory_search oder Cold-Memory)"},
"title": {"type": "string", "description": "Neuer Titel (optional)"},
"content": {"type": "string", "description": "Neuer Content — wird neu embedded fuer Search (optional)"},
"category": {"type": "string", "description": "Neue Kategorie (optional)"},
"tags": {"type": "array", "items": {"type": "string"}, "description": "Neue Tags (ueberschreibt komplett)"},
"pinned": {"type": "boolean", "description": "Pinning aendern (optional)"},
},
"required": ["id"],
},
},
},
{
"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 (Vorlieben, Besitz, Orte, "
"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.\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. Bei Bildern: extrahierte Infos REINSCHREIBEN (Texte, Kennungen, Marken, etc.)"},
"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)."},
"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"],
},
},
},
]
@@ -241,6 +361,14 @@ def _skill_to_tool(s: dict) -> dict:
class Agent:
# Mindest-Score den ein Cold-Memory-Treffer haben muss um in den
# System-Prompt aufgenommen zu werden. Unter dieser Schwelle ist's
# Rauschen — die MiniLM-multilingual Embeddings haben fuer "irgendwas
# vs. irgendwas anderes" gerne mal 0.10-0.20 Score selbst bei voellig
# unverwandten Inhalten. Mit 0.30 als Untergrenze vermeiden wir
# Cross-Talk (z.B. 'hab ich ein flugzeug' triggert die Firmenadresse).
COLD_SCORE_THRESHOLD = 0.30
def __init__(self, store: VectorStore, embedder: Embedder,
conversation: Conversation, proxy: ProxyClient,
cold_k: int = 5):
@@ -278,10 +406,13 @@ class Agent:
# 2. Hot Memory (alle pinned Punkte)
hot = self.store.list_pinned()
# 3. Cold Memory (Top-K semantic)
# 3. Cold Memory (Top-K semantic) — mit Score-Threshold gegen Rauschen
try:
qvec = self.embedder.embed(user_message)
cold = self.store.search(qvec, k=self.cold_k, exclude_pinned=True)
cold = self.store.search(
qvec, k=self.cold_k, exclude_pinned=True,
score_threshold=self.COLD_SCORE_THRESHOLD,
)
except Exception as exc:
logger.warning("Cold-Search fehlgeschlagen: %s", exc)
cold = []
@@ -467,6 +598,159 @@ class Agent:
else:
lines.append(f"- {t['name']} ({t['type']}, {state})")
return "\n".join(lines)
if name == "memory_search":
query = (arguments.get("query") or "").strip()
if not query:
return "FEHLER: query ist Pflicht."
mode = arguments.get("mode") or "text"
try:
k = int(arguments.get("k", 5))
except (TypeError, ValueError):
k = 5
k = max(1, min(k, 20))
try:
if mode == "semantic":
qvec = self.embedder.embed(query)
results = self.store.search(
qvec, k=k, exclude_pinned=False, score_threshold=0.30,
)
else:
results = self.store.search_text(query, k=k, exclude_pinned=False)
if not results:
return f"Keine Treffer fuer '{query}' (mode={mode})."
lines = [f"{len(results)} Treffer fuer '{query}' (mode={mode}):"]
for m in results:
score_part = f" [score={m.score:.2f}]" if m.score is not None else ""
pin = "📌 " if m.pinned else ""
atts = m.attachments or []
att_part = f" 📎{len(atts)}" if atts else ""
lines.append("")
lines.append(f"## {pin}{m.title} ({m.type}){score_part}{att_part}")
lines.append(f"id: {m.id}")
lines.append(m.content or "")
if atts:
for a in atts:
lines.append(f" 📎 {a.get('name', '?')} ({a.get('mime', '')}) — {a.get('path', '')}")
return "\n".join(lines)
except Exception as e:
logger.exception("memory_search fehlgeschlagen")
return f"FEHLER: {e}"
if name == "memory_update":
pid = (arguments.get("id") or "").strip()
if not pid:
return "FEHLER: id ist Pflicht."
existing = self.store.get(pid)
if not existing:
return f"FEHLER: Memory mit id={pid[:8]} nicht gefunden."
try:
from memory.vector_store import COLLECTION
import datetime as _dt
content_changed = False
if "title" in arguments and arguments["title"] is not None:
existing.title = str(arguments["title"]).strip()
if "content" in arguments and arguments["content"] is not None:
new_content = str(arguments["content"]).strip()
if new_content != existing.content:
content_changed = True
existing.content = new_content
if "category" in arguments and arguments["category"] is not None:
existing.category = str(arguments["category"]).strip()
if "tags" in arguments and arguments["tags"] is not None:
existing.tags = [str(t).strip() for t in (arguments["tags"] or []) if str(t).strip()]
if "pinned" in arguments and arguments["pinned"] is not None:
existing.pinned = bool(arguments["pinned"])
existing.updated_at = _dt.datetime.now(_dt.timezone.utc).isoformat()
if content_changed:
vec = self.embedder.embed(existing.content)
self.store.upsert(existing, vec)
else:
self.store.client.set_payload(
collection_name=COLLECTION,
payload=existing.to_payload() | {"updated_at": existing.updated_at},
points=[pid],
)
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,
"attachments": saved.attachments or [],
},
})
return f"OK — Memory '{saved.title}' aktualisiert (id={pid[:8]})."
except Exception as e:
logger.exception("memory_update fehlgeschlagen")
return f"FEHLER: {e}"
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))
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)
point = MemoryPoint(
id="", type=mem_type, title=title, content=content,
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",
"memory": {
"id": saved.id, "type": saved.type, "title": saved.title,
"content_preview": (saved.content or "")[:140],
"category": saved.category, "pinned": saved.pinned,
"attachments": saved.attachments or [],
},
})
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}"
return f"Unbekanntes Tool: {name}"
except Exception as exc:
logger.exception("Tool '%s' fehlgeschlagen", name)
+146 -1
View File
@@ -23,7 +23,7 @@ from typing import List, Optional
import asyncio
from contextlib import asynccontextmanager
from fastapi import FastAPI, HTTPException, BackgroundTasks, Request
from fastapi import FastAPI, HTTPException, BackgroundTasks, Request, UploadFile, File
from fastapi.responses import Response
from pydantic import BaseModel, Field
@@ -114,6 +114,10 @@ class MemoryIn(BaseModel):
source: str = "manual"
tags: List[str] = Field(default_factory=list)
conversation_id: Optional[str] = None
# Vorhandene Anhang-Metadaten beim Save mitgeben (i.d.R. werden Anhaenge
# nach dem Save via /memory/{id}/attachments hinzugefuegt — hier eher fuer
# Bootstrap-Import/Restore-Faelle relevant).
attachments: List[dict] = Field(default_factory=list)
class MemoryUpdate(BaseModel):
@@ -137,12 +141,19 @@ class MemoryOut(BaseModel):
updated_at: str
conversation_id: Optional[str] = None
score: Optional[float] = None
attachments: List[dict] = Field(default_factory=list)
@classmethod
def from_point(cls, p: MemoryPoint) -> "MemoryOut":
return cls(**p.__dict__)
class AttachmentUploadBody(BaseModel):
"""Base64-Upload via JSON — Diagnostic schickt Files so."""
name: str
data_base64: str
# ─── Health ───────────────────────────────────────────────────────────
@app.get("/health")
@@ -181,6 +192,23 @@ def memory_pinned():
return [MemoryOut.from_point(p) for p in store().list_pinned()]
@app.get("/memory/search-text", response_model=List[MemoryOut])
def memory_search_text(
q: str,
k: int = 50,
type: Optional[str] = None,
include_pinned: bool = True,
):
"""Volltext-Substring-Suche (case-insensitive) ueber Title + Content +
Category + Tags. Findet exakte Begriffe — z.B. 'auto' matched 'Stefans Auto'.
Im Gegensatz zu /memory/search (semantic) keine 'klingt aehnlich'-Treffer."""
points = store().search_text(
q, k=k, type_filter=type,
exclude_pinned=not include_pinned,
)
return [MemoryOut.from_point(p) for p in points]
@app.get("/memory/search", response_model=List[MemoryOut])
def memory_search(
q: str,
@@ -214,6 +242,7 @@ def memory_save(body: MemoryIn):
source=body.source,
tags=body.tags,
conversation_id=body.conversation_id,
attachments=body.attachments or [],
)
pid = s.upsert(point, vec)
saved = s.get(pid)
@@ -262,9 +291,125 @@ def memory_delete(point_id: str):
if not s.get(point_id):
raise HTTPException(404, f"Memory {point_id} nicht gefunden")
s.delete(point_id)
# Anhaenge mit-loeschen damit nichts verwaist
try:
import memory_attachments as mem_att
n = mem_att.delete_all(point_id)
if n:
logger.info("Memory %s + %d Anhaenge geloescht", point_id, n)
except Exception as exc:
logger.warning("Anhang-Cleanup fuer %s fehlgeschlagen: %s", point_id, exc)
return {"deleted": point_id}
# ─── Memory-Anhaenge ──────────────────────────────────────────────────
@app.get("/memory/{point_id}/attachments")
def memory_attachments_list(point_id: str):
"""Liste der Anhaenge zum Memory. Source-of-Truth ist das Payload
in der DB, aber wir mergen vorsichtshalber mit dem Filesystem-Stand
(falls ein Upload-Restart zwischendrin schiefging)."""
import memory_attachments as mem_att
s = store()
m = s.get(point_id)
if not m:
raise HTTPException(404, f"Memory {point_id} nicht gefunden")
return {"memory_id": point_id, "attachments": mem_att.list_attachments(point_id)}
def _commit_attachment_meta(point_id: str, meta: dict) -> MemoryOut:
"""Shared-Helper: nach FS-Write das Payload um den neuen Anhang updaten.
Duplikat-Name wird ersetzt, sonst hinten dran."""
s = store()
m = s.get(point_id)
if not m:
raise HTTPException(404, f"Memory {point_id} nicht gefunden")
atts = [a for a in (m.attachments or []) if a.get("name") != meta["name"]]
atts.append(meta)
m.attachments = atts
from memory.vector_store import COLLECTION
import datetime as _dt
m.updated_at = _dt.datetime.now(_dt.timezone.utc).isoformat()
s.client.set_payload(
collection_name=COLLECTION,
payload=m.to_payload() | {"updated_at": m.updated_at},
points=[point_id],
)
return MemoryOut.from_point(s.get(point_id))
@app.post("/memory/{point_id}/attachments", response_model=MemoryOut)
def memory_attachments_add(point_id: str, body: AttachmentUploadBody):
"""Anhang als Base64 hochladen — fuer Diagnostic + interne Tools.
Fuer grosse Files lieber multipart-Variante (/upload) nutzen,
Base64 sprengt schnell die Bash-ARG_MAX-Grenze beim curl."""
import memory_attachments as mem_att
if not store().get(point_id):
raise HTTPException(404, f"Memory {point_id} nicht gefunden")
try:
meta = mem_att.save_from_base64(point_id, body.name, body.data_base64)
except ValueError as exc:
raise HTTPException(400, str(exc))
return _commit_attachment_meta(point_id, meta)
@app.post("/memory/{point_id}/attachments/upload", response_model=MemoryOut)
async def memory_attachments_upload(point_id: str, file: UploadFile = File(...)):
"""Multipart-Upload — Standard fuer Browser-FormData und curl -F.
Verwendung:
curl -F file=@foto.jpg "$ARIA_BRAIN_URL/memory/<id>/attachments/upload"
"""
import memory_attachments as mem_att
if not store().get(point_id):
raise HTTPException(404, f"Memory {point_id} nicht gefunden")
data = await file.read()
try:
meta = mem_att.save_attachment(point_id, file.filename or "datei", data)
except ValueError as exc:
raise HTTPException(400, str(exc))
return _commit_attachment_meta(point_id, meta)
@app.delete("/memory/{point_id}/attachments/{filename}", response_model=MemoryOut)
def memory_attachments_delete(point_id: str, filename: str):
"""Einzelnen Anhang loeschen (FS + Payload-Eintrag)."""
import memory_attachments as mem_att
s = store()
m = s.get(point_id)
if not m:
raise HTTPException(404, f"Memory {point_id} nicht gefunden")
removed_fs = mem_att.delete_attachment(point_id, filename)
safe = filename # Cleanup synchron mit FS — Payload-Match per name
atts = [a for a in (m.attachments or []) if a.get("name") not in (filename, safe)]
m.attachments = atts
from qdrant_client.http import models as qm
from memory.vector_store import COLLECTION
import datetime as _dt
m.updated_at = _dt.datetime.now(_dt.timezone.utc).isoformat()
s.client.set_payload(
collection_name=COLLECTION,
payload=m.to_payload() | {"updated_at": m.updated_at},
points=[point_id],
)
if not removed_fs and not atts:
# weder im FS noch im Payload war was — Anhang existierte nicht
raise HTTPException(404, f"Anhang {filename} nicht gefunden")
return MemoryOut.from_point(s.get(point_id))
@app.get("/memory/{point_id}/attachments/{filename}")
def memory_attachments_get(point_id: str, filename: str):
"""Liefert die Bytes eines Anhangs. Diagnostic-Server kann das
durchproxien zur Vorschau/Download in der UI."""
import memory_attachments as mem_att
import mimetypes as _mt
data = mem_att.read_bytes(point_id, filename)
if data is None:
raise HTTPException(404, f"Anhang {filename} nicht gefunden")
mime = _mt.guess_type(filename)[0] or "application/octet-stream"
return Response(content=data, media_type=mime)
# ─── Migration aus brain-import/ ──────────────────────────────────────
IMPORT_DIR = os.environ.get("IMPORT_DIR", "/import")
+60
View File
@@ -60,6 +60,11 @@ class MemoryPoint:
updated_at: str = ""
conversation_id: Optional[str] = None
score: Optional[float] = None # nur bei Search gesetzt
# Anhaenge: Liste von Dicts {name, mime, size, path} — Dateien liegen
# physisch unter /shared/memory-attachments/<memory-id>/<name>.
# Hier in der DB nur die Metadaten, damit die Suche/Anzeige sie kennt
# ohne Filesystem zu pruefen.
attachments: List[dict] = field(default_factory=list)
def to_payload(self) -> dict:
p = {
@@ -72,6 +77,7 @@ class MemoryPoint:
"tags": self.tags,
"created_at": self.created_at,
"updated_at": self.updated_at,
"attachments": self.attachments,
}
if self.conversation_id:
p["conversation_id"] = self.conversation_id
@@ -92,6 +98,7 @@ class MemoryPoint:
created_at=payload.get("created_at", ""),
updated_at=payload.get("updated_at", ""),
conversation_id=payload.get("conversation_id"),
attachments=payload.get("attachments", []) or [],
score=getattr(point, "score", None),
)
@@ -213,3 +220,56 @@ class VectorStore:
def count(self) -> int:
return self.client.count(collection_name=COLLECTION, exact=True).count
def search_text(
self,
query: str,
k: int = 20,
type_filter: Optional[str] = None,
exclude_pinned: bool = False,
) -> List[MemoryPoint]:
"""Volltext-Substring-Suche (case-insensitive) ueber Title +
Content + Category + Tags. Im Gegensatz zu search() ist das KEIN
Semantic-Match — nur exakte Wort-/Teilwort-Treffer.
Full-Scan ueber alle (gefilteren) Punkte. Bei der erwarteten
Groessenordnung (< 1000) unkritisch."""
q = (query or "").strip().lower()
if not q:
return []
must = []
must_not = []
if type_filter:
must.append(qm.FieldCondition(key="type", match=qm.MatchValue(value=type_filter)))
if exclude_pinned:
must_not.append(qm.FieldCondition(key="pinned", match=qm.MatchValue(value=True)))
flt = qm.Filter(must=must or None, must_not=must_not or None) if (must or must_not) else None
matches: List[MemoryPoint] = []
offset = None
while True:
points, offset = self.client.scroll(
collection_name=COLLECTION,
scroll_filter=flt,
limit=200,
offset=offset,
with_payload=True,
with_vectors=False,
)
for p in points:
payload = p.payload or {}
tags = payload.get("tags")
tags_str = " ".join(tags) if isinstance(tags, list) else ""
haystack = " ".join([
str(payload.get("title", "")),
str(payload.get("content", "")),
str(payload.get("category", "")),
tags_str,
]).lower()
if q in haystack:
matches.append(MemoryPoint.from_qdrant(p))
if len(matches) >= k:
return matches
if not offset:
break
return matches
+172
View File
@@ -0,0 +1,172 @@
"""
Anhaenge fuer Memory-Eintraege.
Storage-Layout:
/shared/memory-attachments/<memory-id>/<original-name>
Eine flache Ordnerstruktur pro Memory — bei Memory-Delete loescht main.py
das ganze Verzeichnis. Anhang-Metadaten (name, mime, size, path) liegen
zusaetzlich im Qdrant-Payload des Memory-Punkts damit die Listen/Suche
sie ohne Filesystem-Lookup zeigen kann.
Anhaenge sind erstmal nur ueber die Diagnostic-UI hochladbar — ARIA
selbst hat in Stufe A kein Tool zum Upload.
"""
from __future__ import annotations
import base64
import logging
import mimetypes
import os
import re
import shutil
from pathlib import Path
from typing import List, Optional
logger = logging.getLogger(__name__)
ROOT = Path(os.environ.get("MEMORY_ATTACHMENTS_DIR", "/shared/memory-attachments"))
MAX_BYTES = int(os.environ.get("MEMORY_ATTACHMENT_MAX_BYTES", str(20 * 1024 * 1024))) # 20 MB
SAFE_NAME_RE = re.compile(r"[^A-Za-z0-9._\-]")
def _safe_filename(name: str) -> str:
"""Macht aus einem User-Namen einen filesystem-sicheren String —
zerlegt Pfadteile, schneidet Sonderzeichen weg, kuerzt auf 120 Zeichen."""
base = Path(name).name or "datei"
base = SAFE_NAME_RE.sub("_", base).strip("._-") or "datei"
return base[:120]
def memory_dir(memory_id: str) -> Path:
return ROOT / memory_id
def list_attachments(memory_id: str) -> List[dict]:
"""Liest die Anhaenge fuer eine Memory aus dem Filesystem.
Returns [{name, mime, size, path}, ...] — leer wenn nichts da.
Source of Truth ist Qdrant-Payload; diese Funktion ist nur fuer
Diagnostic-Endpoints wenn Stefan direkt das FS prueft."""
d = memory_dir(memory_id)
if not d.is_dir():
return []
out = []
for f in sorted(d.iterdir()):
if not f.is_file():
continue
out.append(_file_meta(memory_id, f))
return out
def _file_meta(memory_id: str, f: Path) -> dict:
try:
size = f.stat().st_size
except Exception:
size = 0
mime = mimetypes.guess_type(f.name)[0] or "application/octet-stream"
return {
"name": f.name,
"mime": mime,
"size": size,
"path": str(f), # absoluter Pfad im Container
}
def save_attachment(memory_id: str, filename: str, data: bytes) -> dict:
"""Schreibt einen Anhang ins FS und gibt seine Metadaten zurueck.
Ueberschreibt eine bestehende Datei mit gleichem Namen."""
if not memory_id:
raise ValueError("memory_id ist Pflicht")
if len(data) > MAX_BYTES:
raise ValueError(f"Anhang zu gross ({len(data)} > {MAX_BYTES} Byte)")
safe = _safe_filename(filename)
d = memory_dir(memory_id)
d.mkdir(parents=True, exist_ok=True)
target = d / safe
target.write_bytes(data)
logger.info("[mem-att] %s -> %s (%d Byte)", memory_id, safe, len(data))
return _file_meta(memory_id, target)
def save_from_base64(memory_id: str, filename: str, b64: str) -> dict:
"""Convenience fuer Base64-Uploads (Diagnostic schickt Files so)."""
try:
data = base64.b64decode(b64, validate=False)
except Exception as exc:
raise ValueError(f"Base64-Decode fehlgeschlagen: {exc}") from exc
return save_attachment(memory_id, filename, data)
def delete_attachment(memory_id: str, filename: str) -> bool:
"""Loescht eine einzelne Anhang-Datei. Returns True wenn was weg ist."""
safe = _safe_filename(filename)
target = memory_dir(memory_id) / safe
if not target.is_file():
return False
try:
target.unlink()
logger.info("[mem-att] %s/%s geloescht", memory_id, safe)
return True
except Exception as exc:
logger.warning("[mem-att] Loeschen fehlgeschlagen: %s", exc)
return False
def delete_all(memory_id: str) -> int:
"""Loescht das komplette Memory-Verzeichnis. Wird beim Memory-Delete
in main.py gerufen damit nichts verwaist."""
d = memory_dir(memory_id)
if not d.is_dir():
return 0
count = sum(1 for _ in d.iterdir() if _.is_file())
try:
shutil.rmtree(d)
logger.info("[mem-att] %s komplett entfernt (%d Files)", memory_id, count)
except Exception as exc:
logger.warning("[mem-att] rmtree fehlgeschlagen: %s", exc)
return count
def read_bytes(memory_id: str, filename: str) -> Optional[bytes]:
"""Liefert die rohen Bytes einer Datei zurueck — fuer Download/Serve."""
safe = _safe_filename(filename)
target = memory_dir(memory_id) / safe
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)
+47
View File
@@ -52,6 +52,44 @@ TYPE_HEADINGS = {
}
def _attachments_line(p: MemoryPoint) -> str:
"""Eine Zeile die ARIA verraet welche Dateien an einer Memory haengen.
Bilder/Files liegen physisch unter /shared/memory-attachments/<id>/<name>.
Multi-Modal-Hinweis: Claude Code's `Read`-Tool kann Bilder direkt
anschauen (PNG/JPG/GIF/WebP) — sie laufen dann durch das gleiche
Vision-Modell wie via Anthropic-Vision-API. Heisst: ARIA muss nur
`Read /shared/memory-attachments/<id>/foto.jpg` aufrufen und sieht
das Bild wirklich, ohne dass wir Multi-Modal-Messages durch den
Proxy schleusen muessen. Wir geben ihr den Hinweis in der Zeile mit.
"""
atts = getattr(p, "attachments", None) or []
if not atts:
return ""
base_dir = f"/shared/memory-attachments/{p.id}/" if p.id else ""
items = []
has_image = False
for a in atts:
if not isinstance(a, dict):
continue
name = a.get("name", "?")
mime = a.get("mime", "")
if mime.startswith("image/"):
has_image = True
size = a.get("size")
size_part = f", {size // 1024} KB" if isinstance(size, int) and size else ""
items.append(f"{name} ({mime}{size_part})")
if not items:
return ""
line = f"📎 Anhaenge: {', '.join(items)}"
if base_dir:
line += f" — Pfad: {base_dir}"
if has_image and base_dir:
line += (" — Bilder kannst du via `Read <pfad>` direkt ansehen "
"(Claude Code Read ist multi-modal-faehig)")
return line
def build_hot_memory_section(pinned: List[MemoryPoint]) -> str:
"""Baue den 'IMMER-im-Prompt'-Block aus pinned Punkten."""
grouped: dict[str, List[MemoryPoint]] = {}
@@ -69,6 +107,9 @@ def build_hot_memory_section(pinned: List[MemoryPoint]) -> str:
for p in items:
parts.append(f"### {p.title}")
parts.append(p.content.strip())
att_line = _attachments_line(p)
if att_line:
parts.append(att_line)
parts.append("")
# uebrige Types (falls jemand was anderes als pinned markiert)
@@ -77,6 +118,9 @@ def build_hot_memory_section(pinned: List[MemoryPoint]) -> str:
for p in items:
parts.append(f"### {p.title}")
parts.append(p.content.strip())
att_line = _attachments_line(p)
if att_line:
parts.append(att_line)
parts.append("")
return "\n".join(parts).strip()
@@ -91,6 +135,9 @@ def build_cold_memory_section(matches: List[MemoryPoint]) -> str:
score = f" [score={p.score:.2f}]" if p.score is not None else ""
lines.append(f"- **{p.title}**{score}")
lines.append(f" {p.content.strip()}")
att_line = _attachments_line(p)
if att_line:
lines.append(f" {att_line}")
return "\n".join(lines)
+17
View File
@@ -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)
+412 -18
View File
@@ -824,11 +824,17 @@
</div>
<div class="card" style="margin-bottom:8px;">
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;">
<input type="text" id="brain-search" placeholder="Semantische Suche (z.B. 'Stefan Persönlichkeit')..."
<input type="text" id="brain-search" placeholder="Suche (z.B. 'cessna' oder 'Stefan Persönlichkeit')..."
style="flex:1;min-width:200px;background:#080810;color:#E0E0F0;border:1px solid #1E1E2E;padding:6px 8px;border-radius:4px;font-family:inherit;font-size:12px;"
onkeydown="if(event.key==='Enter') runBrainSearch()">
<select id="brain-search-mode" onchange="if(document.getElementById('brain-search').value.trim()) runBrainSearch()"
title="Wortlich = exakter Substring-Match. Semantisch = 'klingt aehnlich' via Embeddings."
style="background:#080810;color:#E0E0F0;border:1px solid #1E1E2E;padding:6px;border-radius:4px;font-family:inherit;font-size:11px;">
<option value="text" selected>📝 Wortlich</option>
<option value="semantic">🧠 Semantisch</option>
</select>
<button class="btn secondary" onclick="runBrainSearch()" style="padding:4px 12px;font-size:11px;">Suchen</button>
<select id="brain-filter-type" onchange="loadBrainMemoryList()"
<select id="brain-filter-type" onchange="onBrainFiltersChanged()"
style="background:#080810;color:#E0E0F0;border:1px solid #1E1E2E;padding:6px;border-radius:4px;font-family:inherit;font-size:11px;">
<option value="">Alle Typen</option>
<option value="identity">Identität</option>
@@ -840,14 +846,29 @@
<option value="conversation">Konversation</option>
<option value="reminder">Reminder</option>
</select>
<select id="brain-filter-pinned" onchange="loadBrainMemoryList()"
<select id="brain-filter-pinned" onchange="onBrainFiltersChanged()"
style="background:#080810;color:#E0E0F0;border:1px solid #1E1E2E;padding:6px;border-radius:4px;font-family:inherit;font-size:11px;">
<option value="all">Pinned + Cold</option>
<option value="pinned">📌 Nur Pinned</option>
<option value="cold">Nur Cold</option>
</select>
<button class="btn secondary" onclick="toggleAdvancedSearch()" id="btn-advanced-search" style="padding:4px 8px;font-size:11px;color:#8888AA;" title="Erweiterte Suche mit AND/OR-Verknuepfungen">⌃ Erweitert</button>
<button class="btn secondary" onclick="resetBrainFilters();loadBrainMemoryList()" style="padding:4px 8px;font-size:11px;color:#8888AA;" title="Suche + Filter zurücksetzen"></button>
</div>
<div id="brain-advanced-panel" style="display:none;margin-top:10px;padding:10px;background:#080810;border:1px solid #1E1E2E;border-radius:6px;">
<div style="color:#8888AA;font-size:11px;margin-bottom:6px;">
Mehrere Begriffe mit AND/OR verknuepfen — Volltext-Substring, case-insensitive, links-nach-rechts ausgewertet.
</div>
<div id="adv-rows-container" style="display:flex;flex-direction:column;gap:6px;">
<!-- Reihen werden dynamisch via JS gerendert (renderAdvancedRows) -->
</div>
<div style="display:flex;gap:6px;margin-top:8px;align-items:center;flex-wrap:wrap;">
<button class="btn" onclick="runAdvancedSearch()" style="padding:4px 12px;font-size:11px;">Suchen</button>
<button class="btn secondary" onclick="addAdvancedRow()" style="padding:4px 10px;font-size:11px;" title="Weiteres Suchfeld hinzufuegen">+ Feld</button>
<button class="btn secondary" onclick="clearAdvancedSearch()" style="padding:4px 10px;font-size:11px;color:#8888AA;">Alle leeren</button>
<span style="color:#555570;font-size:10px;margin-left:auto;">Leere Felder werden ignoriert · Min. 1 Feld · ✕ entfernt ein Feld</span>
</div>
</div>
<div id="brain-search-info" style="margin-top:6px;font-size:10px;color:#8888AA;display:none;"></div>
</div>
<div class="card">
@@ -1022,6 +1043,26 @@
<input type="checkbox" id="memory-pinned">
<span>📌 Pinned (Hot Memory — IMMER im System-Prompt)</span>
</label>
<!-- Anhaenge — nur bei Edit (vorhandene ID) sichtbar -->
<div id="memory-attachments-block" style="display:none;margin-top:14px;padding-top:10px;border-top:1px solid #1E1E2E;">
<label style="display:flex;align-items:center;justify-content:space-between;font-size:11px;color:#8888AA;margin-bottom:6px;">
<span>📎 Anhaenge</span>
<span style="color:#555570;font-size:10px;">max 20 MB pro Datei</span>
</label>
<div id="memory-attachments-list" style="display:flex;flex-direction:column;gap:4px;margin-bottom:6px;font-size:12px;color:#555570;"></div>
<div style="display:flex;gap:6px;align-items:center;">
<label class="btn secondary" style="padding:4px 10px;font-size:11px;cursor:pointer;margin:0;">
⬆ Datei waehlen
<input type="file" id="memory-attachment-input" multiple style="display:none;" onchange="uploadMemoryAttachments(this.files)">
</label>
<span id="memory-attachment-status" style="font-size:11px;color:#555570;"></span>
</div>
</div>
<div id="memory-attachments-hint" style="display:none;margin-top:10px;padding:6px 8px;background:#0D0D1A;border-radius:4px;color:#555570;font-size:11px;">
📎 Anhaenge kannst du nach dem Speichern hinzufuegen (brauchen eine Memory-ID).
</div>
<div id="memory-modal-error" style="color:#FF6B6B;font-size:11px;margin-top:10px;display:none;"></div>
</div>
<div class="modal-footer" style="padding:10px 16px;border-top:1px solid #1E1E2E;display:flex;justify-content:flex-end;gap:8px;">
@@ -1362,6 +1403,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');
@@ -1955,6 +2004,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
diesem serverPath rerendern als "geloescht" markieren. */
function markFileDeletedInChat(serverPath) {
@@ -3445,6 +3527,192 @@
const p = document.getElementById('brain-filter-pinned'); if (p) p.value = 'all';
const info = document.getElementById('brain-search-info'); if (info) info.style.display = 'none';
brainSearchIds = null;
clearAdvancedSearch();
}
function toggleAdvancedSearch() {
const panel = document.getElementById('brain-advanced-panel');
const btn = document.getElementById('btn-advanced-search');
if (!panel) return;
const open = panel.style.display !== 'none';
panel.style.display = open ? 'none' : 'block';
if (btn) btn.textContent = open ? '⌃ Erweitert' : '⌄ Einklappen';
if (!open) ensureAdvancedRows();
}
// Dynamische Such-Reihen-Struktur:
// advRows = [{term, op}, ...] — die erste Reihe hat op=null,
// jede weitere bekommt einen UND/ODER-Selektor links und einen ✕ rechts.
let advRows = [{ term: '', op: null }];
function ensureAdvancedRows() {
if (!advRows.length) advRows = [{ term: '', op: null }];
renderAdvancedRows();
}
function addAdvancedRow() {
// Vor dem Re-render aktuelle Werte aus DOM uebernehmen damit nichts verloren geht
syncAdvancedRowsFromDOM();
advRows.push({ term: '', op: 'AND' });
renderAdvancedRows();
// Fokus auf das neue Feld
const last = document.querySelector(`#adv-rows-container .adv-row:last-child input.adv-term`);
if (last) last.focus();
}
function removeAdvancedRow(idx) {
syncAdvancedRowsFromDOM();
if (advRows.length <= 1) return; // erste bleibt
advRows.splice(idx, 1);
// Erste Reihe hat immer op=null
if (advRows[0]) advRows[0].op = null;
renderAdvancedRows();
}
function syncAdvancedRowsFromDOM() {
const rows = document.querySelectorAll('#adv-rows-container .adv-row');
const next = [];
rows.forEach((row, i) => {
const term = (row.querySelector('input.adv-term')?.value || '');
const op = i === 0 ? null : (row.querySelector('select.adv-op')?.value || 'AND');
next.push({ term, op });
});
if (next.length) advRows = next;
}
function renderAdvancedRows() {
const container = document.getElementById('adv-rows-container');
if (!container) return;
const inputStyle = 'flex:1;min-width:0;background:#080810;color:#E0E0F0;border:1px solid #1E1E2E;padding:6px;border-radius:4px;font-family:inherit;font-size:12px;';
const selectStyle = 'background:#080810;color:#E0E0F0;border:1px solid #1E1E2E;padding:6px;border-radius:4px;font-family:inherit;font-size:11px;width:70px;';
container.innerHTML = advRows.map((r, i) => {
const ph = i === 0 ? 'z.B. flugzeug' : 'z.B. cessna';
const term = (r.term || '').replace(/"/g, '&quot;');
if (i === 0) {
return `<div class="adv-row" style="display:flex;gap:6px;align-items:center;">
<span style="width:70px;color:#555570;font-size:11px;text-align:center;">Start</span>
<input type="text" class="adv-term" placeholder="${ph}" value="${term}" style="${inputStyle}">
<span style="width:24px;"></span>
</div>`;
}
const op = r.op || 'AND';
return `<div class="adv-row" style="display:flex;gap:6px;align-items:center;">
<select class="adv-op" style="${selectStyle}">
<option value="AND"${op === 'AND' ? ' selected' : ''}>UND</option>
<option value="OR"${op === 'OR' ? ' selected' : ''}>ODER</option>
</select>
<input type="text" class="adv-term" placeholder="${ph}" value="${term}" style="${inputStyle}">
<button class="btn secondary" onclick="removeAdvancedRow(${i})" title="Diese Zeile entfernen" style="width:24px;height:24px;padding:0;line-height:20px;font-size:11px;color:#FF6B6B;"></button>
</div>`;
}).join('');
}
function clearAdvancedSearch() {
advRows = [{ term: '', op: null }];
renderAdvancedRows();
}
/** Mehrere Volltext-Suchen + Boolean-Kombination (links nach rechts).
* Backend bleibt simpel — wir machen N parallele search-text-Calls
* und kombinieren die ID-Mengen client-seitig per AND/OR. */
async function runAdvancedSearch() {
syncAdvancedRowsFromDOM();
const info = document.getElementById('brain-search-info');
// Nur Reihen mit Inhalt einsammeln. Die erste belegte Reihe wird zum
// Start-Term (op=null), egal an welchem Index sie ursprünglich war.
const active = [];
for (const r of advRows) {
const t = (r.term || '').trim();
if (!t) continue;
active.push({ term: t, op: active.length === 0 ? null : (r.op || 'AND') });
}
if (active.length === 0) {
if (info) info.style.display = 'none';
loadBrainMemoryList();
return;
}
const typeFilter = document.getElementById('brain-filter-type').value;
const baseParams = { k: '500', include_pinned: 'true' };
if (typeFilter) baseParams.type = typeFilter;
try {
// Pro Begriff einmal Backend fragen, dann Map<id, memory> + Set<id>
const sets = [];
for (const a of active) {
const params = new URLSearchParams({ ...baseParams, q: a.term });
const r = await fetch('/api/brain/memory/search-text?' + params.toString());
if (!r.ok) throw new Error('HTTP ' + r.status);
const hits = await r.json();
hits.forEach(m => { brainMemoryCache[m.id] = m; });
sets.push(new Set(hits.map(m => m.id)));
}
// Links-nach-rechts kombinieren mit den Operatoren
let combined = sets[0];
for (let i = 1; i < sets.length; i++) {
const op = active[i].op;
if (op === 'AND') {
combined = new Set([...combined].filter(id => sets[i].has(id)));
} else {
combined = new Set([...combined, ...sets[i]]);
}
}
let hits = Array.from(combined).map(id => brainMemoryCache[id]).filter(Boolean);
const totalHits = hits.length;
hits = applyPinnedFilter(hits);
brainSearchIds = hits.map(m => m.id);
const desc = active.map((a, i) => i === 0 ? `"${a.term}"` : ` ${a.op} "${a.term}"`).join('');
const pinnedFilter = document.getElementById('brain-filter-pinned')?.value || 'all';
const pinnedLabel = pinnedFilter === 'pinned' ? ' · 📌 nur pinned'
: pinnedFilter === 'cold' ? ' · nur cold'
: '';
if (info) {
info.style.display = 'block';
const filterDesc = (typeFilter ? ` · Typ=${escapeHtml(typeFilter)}` : '') + pinnedLabel;
if (hits.length === 0) {
const extra = totalHits > 0 ? ` (${totalHits} Treffer ohne Pinned-Filter)` : '';
info.innerHTML = `🔍 Keine Treffer fuer ${escapeHtml(desc)}${filterDesc}${extra} · 📝 wortlich, Boolean-Kombi`;
} else {
info.innerHTML = `🔍 ${hits.length} Treffer fuer ${escapeHtml(desc)}${filterDesc} · 📝 wortlich, Boolean-Kombi`;
}
}
renderBrainList(hits, true);
} catch (e) {
if (info) {
info.style.display = 'block';
info.innerHTML = `🔴 Erweiterte Suche fehlgeschlagen: ${escapeHtml(e.message)}`;
}
}
}
/** True wenn aktuell eine Search-Ansicht aktiv ist (Single oder Advanced).
* Wird vom Pinned/Type-Filter-onchange genutzt um statt loadBrainMemoryList
* die Suche neu auszufuehren — damit Filter auch bei aktiver Suche greifen. */
function brainSearchActive() {
const q = (document.getElementById('brain-search')?.value || '').trim();
if (q) return 'single';
const hasAdv = (advRows || []).some(r => (r.term || '').trim());
return hasAdv ? 'advanced' : null;
}
/** Wird vom Type+Pinned-Dropdown onchange gerufen. Bei aktiver Suche
* re-search ausfuehren, sonst Liste neu laden. */
function onBrainFiltersChanged() {
const which = brainSearchActive();
if (which === 'single') runBrainSearch();
else if (which === 'advanced') runAdvancedSearch();
else loadBrainMemoryList();
}
/** Filtert eine Liste von Memories nach dem pinned-Dropdown-Wert.
* 'all' = alles durchlassen, 'pinned' = nur pinned, 'cold' = nur cold. */
function applyPinnedFilter(items) {
const v = document.getElementById('brain-filter-pinned')?.value || 'all';
if (v === 'pinned') return items.filter(m => m.pinned);
if (v === 'cold') return items.filter(m => !m.pinned);
return items;
}
async function runBrainSearch() {
@@ -3457,27 +3725,43 @@
return;
}
const typeFilter = document.getElementById('brain-filter-type').value;
// k=10 + Score-Threshold im Backend (0.30) → nur relevante Treffer.
// Frueher k=20 ohne Threshold: bei kleiner DB landete fast alles
// als "Treffer", egal wie unaehnlich.
const params = new URLSearchParams({ q, k: '10', include_pinned: 'true', score_threshold: '0.30' });
if (typeFilter) params.set('type', typeFilter);
const pinnedFilter = document.getElementById('brain-filter-pinned')?.value || 'all';
const mode = (document.getElementById('brain-search-mode')?.value) || 'text';
let url, modeLabel;
if (mode === 'semantic') {
// Embedder-basiert, mit Score-Threshold gegen Rauschen
const params = new URLSearchParams({ q, k: '20', include_pinned: 'true', score_threshold: '0.30' });
if (typeFilter) params.set('type', typeFilter);
url = '/api/brain/memory/search?' + params.toString();
modeLabel = '🧠 semantisch (Score ≥ 0.30)';
} else {
// Volltext-Substring (case-insensitive) — findet exakte Begriffe
const params = new URLSearchParams({ q, k: '100', include_pinned: 'true' });
if (typeFilter) params.set('type', typeFilter);
url = '/api/brain/memory/search-text?' + params.toString();
modeLabel = '📝 wortlich (Substring)';
}
try {
const r = await fetch('/api/brain/memory/search?' + params.toString());
const r = await fetch(url);
if (!r.ok) throw new Error('HTTP ' + r.status);
const hits = await r.json();
let hits = await r.json();
hits.forEach(m => { brainMemoryCache[m.id] = m; });
// Pinned-Filter clientseitig anwenden — Backend kennt nur include_pinned
// (all-or-none), wir brauchen aber feiner "nur pinned" / "nur cold".
const totalHits = hits.length;
hits = applyPinnedFilter(hits);
brainSearchIds = hits.map(m => m.id);
const pinnedLabel = pinnedFilter === 'pinned' ? ' · 📌 nur pinned'
: pinnedFilter === 'cold' ? ' · nur cold'
: '';
if (info) {
info.style.display = 'block';
const filterDesc = (typeFilter ? ` · Typ=${escapeHtml(typeFilter)}` : '') + pinnedLabel;
if (hits.length === 0) {
info.innerHTML = `🔍 Keine relevanten Treffer für "${escapeHtml(q)}"` +
(typeFilter ? ` · Typ=${escapeHtml(typeFilter)}` : '') +
` (Score < 0.30). Versuche andere Begriffe oder klicke das rechts um die Suche zu schliessen.`;
const extra = totalHits > 0 ? ` (${totalHits} Treffer ohne Pinned-Filter)` : '';
info.innerHTML = `🔍 Keine Treffer für "${escapeHtml(q)}"${filterDesc}${extra} · ${modeLabel}.`;
} else {
info.innerHTML = `🔍 ${hits.length} Treffer für "${escapeHtml(q)}"` +
(typeFilter ? ` · Typ=${escapeHtml(typeFilter)}` : '') +
` · sortiert nach Aehnlichkeit (Score &ge; 0.30)`;
info.innerHTML = `🔍 ${hits.length} Treffer für "${escapeHtml(q)}"${filterDesc} · ${modeLabel}`;
}
}
renderBrainList(hits, true);
@@ -3585,9 +3869,11 @@
const preview = (m.content || '').slice(0, 140).replace(/\n/g, ' ');
const score = withScore && typeof m.score === 'number' ? `<span style="color:#FFD60A;font-size:10px;margin-left:6px;">${m.score.toFixed(2)}</span>` : '';
const typeBadge = withScore ? `<span style="color:#0096FF;font-size:10px;margin-right:6px;">${escapeHtml(BRAIN_TYPE_LABELS[m.type] || m.type)}</span>` : '';
const attCount = Array.isArray(m.attachments) ? m.attachments.length : 0;
const attBadge = attCount > 0 ? `<span style="color:#34C759;font-size:10px;margin-left:6px;" title="${attCount} Anhang${attCount === 1 ? '' : ' / Anhaenge'}">📎${attCount}</span>` : '';
return `<div style="padding:6px 0;border-bottom:1px solid #1E1E2E;display:flex;gap:6px;align-items:flex-start;">
<div style="flex:1;min-width:0;cursor:pointer;" onclick="openMemoryModal('${m.id}')">
<div style="color:#E0E0F0;font-size:12px;">${typeBadge}${pin}<strong>${escapeHtml(m.title || '(ohne Titel)')}</strong>${score}
<div style="color:#E0E0F0;font-size:12px;">${typeBadge}${pin}<strong>${escapeHtml(m.title || '(ohne Titel)')}</strong>${score}${attBadge}
${m.category ? `<span style="color:#555570;font-weight:normal;font-size:10px;margin-left:6px;">[${escapeHtml(m.category)}]</span>` : ''}
</div>
<div style="color:#888;font-size:11px;line-height:1.4;">${escapeHtml(preview)}${m.content && m.content.length > 140 ? '...' : ''}</div>
@@ -3780,6 +4066,13 @@
const errEl = document.getElementById('memory-modal-error');
errEl.style.display = 'none';
const attBlock = document.getElementById('memory-attachments-block');
const attHint = document.getElementById('memory-attachments-hint');
const attStatus = document.getElementById('memory-attachment-status');
if (attStatus) attStatus.textContent = '';
const attInput = document.getElementById('memory-attachment-input');
if (attInput) attInput.value = '';
if (id && brainMemoryCache[id]) {
const m = brainMemoryCache[id];
titleEl.textContent = 'Memory bearbeiten';
@@ -3790,6 +4083,10 @@
document.getElementById('memory-category').value = m.category || '';
document.getElementById('memory-tags').value = (m.tags || []).join(', ');
document.getElementById('memory-pinned').checked = !!m.pinned;
// Anhang-Block sichtbar — Liste rendern
if (attBlock) attBlock.style.display = 'block';
if (attHint) attHint.style.display = 'none';
renderMemoryAttachmentsList(m.attachments || []);
} else {
titleEl.textContent = 'Neue Memory';
idEl.value = '';
@@ -3799,10 +4096,96 @@
document.getElementById('memory-category').value = '';
document.getElementById('memory-tags').value = '';
document.getElementById('memory-pinned').checked = false;
// Bei neuem Memory: nur Hinweis, dass Anhaenge nach Save gehen
if (attBlock) attBlock.style.display = 'none';
if (attHint) attHint.style.display = 'block';
}
modal.classList.add('open');
}
function renderMemoryAttachmentsList(atts) {
const el = document.getElementById('memory-attachments-list');
if (!el) return;
const id = document.getElementById('memory-edit-id').value;
if (!Array.isArray(atts) || atts.length === 0) {
el.innerHTML = '<div style="color:#555570;font-size:11px;font-style:italic;">(noch keine Anhaenge)</div>';
return;
}
el.innerHTML = atts.map(a => {
const name = escapeHtml(a.name || '?');
const mime = a.mime || 'application/octet-stream';
const size = a.size ? `${(a.size / 1024).toFixed(0)} KB` : '';
const isImage = mime.startsWith('image/');
const url = `/api/brain/memory/${encodeURIComponent(id)}/attachments/${encodeURIComponent(a.name)}`;
const preview = isImage
? `<img src="${url}" style="width:32px;height:32px;object-fit:cover;border-radius:4px;cursor:pointer;" onclick="openLightbox('image','${url}')">`
: `<span style="display:inline-block;width:32px;text-align:center;font-size:18px;">📄</span>`;
return `<div style="display:flex;align-items:center;gap:8px;padding:4px 6px;background:#0D0D1A;border-radius:4px;">
${preview}
<a href="${url}" target="_blank" style="flex:1;min-width:0;color:#E0E0F0;text-decoration:none;font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="${name}">${name}</a>
<span style="color:#555570;font-size:10px;flex-shrink:0;">${escapeHtml(mime)}, ${size}</span>
<button class="btn secondary" onclick="deleteMemoryAttachment('${encodeURIComponent(a.name)}')" title="Anhang loeschen" style="padding:2px 6px;font-size:10px;color:#FF6B6B;border-color:#FF6B6B;">🗑</button>
</div>`;
}).join('');
}
async function uploadMemoryAttachments(files) {
if (!files || !files.length) return;
const id = document.getElementById('memory-edit-id').value;
if (!id) return;
const status = document.getElementById('memory-attachment-status');
let lastResult = null;
let n = 0;
for (const file of files) {
if (status) status.textContent = `⏳ Lade ${file.name} (${(file.size/1024).toFixed(0)} KB)...`;
try {
const form = new FormData();
form.append('file', file, file.name);
const r = await fetch(`/api/brain/memory/${encodeURIComponent(id)}/attachments/upload`, {
method: 'POST',
body: form,
});
if (!r.ok) {
const txt = await r.text();
throw new Error('HTTP ' + r.status + ': ' + txt.slice(0, 200));
}
lastResult = await r.json();
n += 1;
} catch (e) {
if (status) status.textContent = `🔴 ${file.name}: ${e.message}`;
break;
}
}
if (lastResult) {
brainMemoryCache[id] = lastResult;
renderMemoryAttachmentsList(lastResult.attachments || []);
if (status) status.textContent = `✓ ${n} Anhang${n === 1 ? '' : '/Anhaenge'} hochgeladen`;
// Eingabe-File-List reset damit erneutes Anwaehlen derselben Datei feuert
const inp = document.getElementById('memory-attachment-input');
if (inp) inp.value = '';
}
}
async function deleteMemoryAttachment(filenameEncoded) {
const id = document.getElementById('memory-edit-id').value;
if (!id) return;
const name = decodeURIComponent(filenameEncoded);
if (!confirm(`Anhang "${name}" wirklich loeschen?`)) return;
try {
const r = await fetch(`/api/brain/memory/${encodeURIComponent(id)}/attachments/${filenameEncoded}`, {
method: 'DELETE',
});
if (!r.ok) throw new Error('HTTP ' + r.status);
const updated = await r.json();
brainMemoryCache[id] = updated;
renderMemoryAttachmentsList(updated.attachments || []);
const status = document.getElementById('memory-attachment-status');
if (status) status.textContent = `✓ "${name}" geloescht`;
} catch (e) {
alert('Loeschen fehlgeschlagen: ' + e.message);
}
}
function closeMemoryModal() {
document.getElementById('memory-modal').classList.remove('open');
}
@@ -3856,7 +4239,18 @@
try {
const r = await fetch('/api/brain/memory/delete/' + encodeURIComponent(id), { method: 'DELETE' });
if (!r.ok) throw new Error('HTTP ' + r.status);
loadBrainMemoryList();
// Lokalen Cache + Such-State bereinigen damit die Liste nicht den Geist
// des geloeschten Eintrags weiterzeigt.
delete brainMemoryCache[id];
if (Array.isArray(brainSearchIds)) {
brainSearchIds = brainSearchIds.filter(x => x !== id);
}
// Re-Render: bei aktiver Suche neu suchen (Filter respektieren),
// sonst die Vollliste neu vom Server holen.
const which = (typeof brainSearchActive === 'function') ? brainSearchActive() : null;
if (which === 'single') await runBrainSearch();
else if (which === 'advanced') await runAdvancedSearch();
else await loadBrainMemoryList();
loadBrainStatus();
} catch (e) {
alert('Löschen fehlgeschlagen: ' + e.message);
+26 -1
View File
@@ -617,6 +617,26 @@ 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 === "agent_activity") {
// Bridge meldet "ARIA denkt/schreibt/tool" oder "idle" — an Browser
// weiterreichen, damit der Thinking-Indikator im Chat erscheint.
// Wenn gerade ein chat:final vorbei ist, unterdruecken wir trailing
// 'thinking'-Events (gleiches Schema wie alter OpenClaw-Pfad).
const activity = msg.payload?.activity || msg.activity || "idle";
if (activity !== "idle" && Date.now() - lastChatFinalAt < SETTLED_WINDOW_MS) {
// chat:final ist gerade durch — verstaubende thinking-Events ignorieren
} else {
broadcast({
type: "agent_activity",
activity,
tool: msg.payload?.tool || msg.tool || "",
});
}
} 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.
@@ -1624,13 +1644,18 @@ const server = http.createServer((req, res) => {
// Reverse-Proxy zum aria-brain Container (intern auf 8080, nicht expose'd).
// Frontend ruft z.B. /api/brain/health → http://aria-brain:8080/health
const targetPath = req.url.replace(/^\/api\/brain/, "");
// Uploads brauchen laenger als die 30s default — Memory-Anhang-Endpoints
// koennen bis zu 20 MB tragen, plus chat/distill-Calls dauern manchmal
// mehr als eine Minute.
const isUpload = /\/attachments(\/upload)?$/.test(targetPath);
const timeout = isUpload ? 120000 : 60000;
const proxyReq = http.request({
host: "aria-brain",
port: 8080,
path: targetPath,
method: req.method,
headers: req.headers,
timeout: 30000,
timeout,
}, (proxyRes) => {
res.writeHead(proxyRes.statusCode, proxyRes.headers);
proxyRes.pipe(res);
+8
View File
@@ -20,6 +20,14 @@ services:
- ./aria-data/ssh:/root/.ssh # SSH Keys fuer VM-Zugriff (aria-wohnung, rw fuer ARIA)
- aria-shared:/shared # Shared Volume fuer Datei-Austausch (Uploads von App)
- ./proxy-patches:/proxy-patches:ro # Tool-Use-Adapter (ueberschreibt npm-Version, read-only)
# Claude Code's eingebautes Auto-Memory liegt in ~/.claude/projects/.
# Wir ueberlagern das mit tmpfs damit ARIA nicht parallel zu ARIAs eigener
# Qdrant-DB ein File-Memory aufbaut (war Auslöser fuer doppelte Truth-Source).
# Tmpfs ist beim Container-Start leer und wird beim Container-Recreate
# weggeworfen — Claude Code sieht keine alten Files mehr und das was sie
# ggf. neu schreibt landet nicht auf dem VM-Host.
tmpfs:
- /root/.claude/projects
environment:
- HOST=0.0.0.0
- SHELL=/bin/bash # Claude Code Bash-Tool braucht bash (nicht nur sh/ash)
+33
View File
@@ -55,6 +55,13 @@ Wichtige Mechanismen:
### Bugs / Fixes
- [x] **Cold Memory Crosstalk** durch Score-Threshold im Brain-Agent: Bei kleiner DB lieferte Cold-Search ungefiltert Top-5, auch wenn alle Scores < 0.2 lagen — ARIA hat das als „relevante" Info in den System-Prompt bekommen und in die Antwort eingewoben. Beispiel: Frage „hab ich ein flugzeug?" → Cold-Top war „Firmenadresse" (Score 0.094, Embedder-Noise) → ARIA antwortete „Die Adresse aus meinem Gedaechtnis ist..." ohne dass User danach gefragt hatte. Fix: Konstante `COLD_SCORE_THRESHOLD=0.30` in `agent.py` an `store.search()` durchgereicht. Konsistent mit dem `/memory/search`-HTTP-Threshold und der Diagnostic-Suche
- [x] **Diagnostic: Pinned-/Type-Filter wirkt jetzt auch bei aktiver Suche**: Vorher ignorierten `runBrainSearch`/`runAdvancedSearch` die Filter-Dropdowns komplett; Dropdown-onchange rief `loadBrainMemoryList` und brach die Suche damit ab. Fix: `applyPinnedFilter` clientseitig nach Backend-Hit, `onBrainFiltersChanged` re-search bei aktiver Suche
- [x] **Diagnostic: Memory-Liste refresht nach Delete sofort**: vorher rendere `loadBrainMemoryList` bei aktiver Such-Ansicht aus `brainMemoryCache` → der gerade geloeschte Eintrag tauchte wieder auf. Fix: Cache + brainSearchIds nach Delete bereinigen + re-search statt list
- [x] **Diagnostic: „ARIA denkt..."-Indikator wieder im Chat-Fenster**: `agent_activity`-Events von RVS wurden vom Diagnostic-Server nicht an Browser durchgereicht. Fix: Relay analog zu `mode`/`voice_ready`, mit `SETTLED_WINDOW_MS`-Schutz gegen Trailing-Events nach `chat:final`
- [x] **Memory-Suche filtert Rauschen** (score_threshold im HTTP-Endpoint + kleineres k): Vorher k=20 ohne Threshold lieferte bei kleiner DB fast alles als Treffer, auch komplettes Rauschen (z.B. „banane" → 10 false positives mit Score 0.10-0.22). Fix: `score_threshold=0.30` als Query-Param am `/memory/search`-Endpoint + Diagnostic schickt jetzt `k=10` + Threshold, „Keine Treffer"-Box wenn alle unter Score
- [x] **Cessna-Beispiel aus System-Prompt raus**: in der `memory_save`-Tool-Description stand „z.B. 'Stefan hat eine Cessna'" als fact-Beispiel. ARIA hat das (korrekt!) korrekt eingeordnet als Beispiel-Text, aber Phantom-Wissen im Prompt ist suboptimal. Fix: durch generische Aufzaehlung (Vorlieben/Besitz/Orte/Termine/Personen) ersetzt
- [x] **Claude-Code-Auto-Memory abklemmen**: Claude Code CLI hat ein eingebautes Auto-Memory das Markdown-Files in `~/.claude/projects/<project>/memory/` schreibt. Weil das CLI als ARIAs LLM lief, hat sie da ueber Wochen ihre eigene Schatten-Wissensbasis aufgebaut (cessna, persoenlichkeit, projects) — komplett parallel zur Qdrant-DB. Fix: `tmpfs`-Mount ueber `/root/.claude/projects` im Proxy-Container. Claude Code sieht beim Spawn leeres `projects/`, schreibt sie was rein landet's nur im RAM, beim Container-Recreate weg. Stefans persoenliches `~/.claude/projects/` auf der VM bleibt unangetastet
- [x] **Trigger-Antworten landen jetzt im Chat** (App + Diagnostic + TTS): Wenn der Brain-Background-Loop einen Timer/Watcher feuert, ruft er `agent.chat()` direkt im eigenen Prozess. Die Antwort wurde nur ins Trigger-Log geschrieben — kein RVS-Broadcast, nichts sichtbar. Fix: Bridge hat jetzt einen kleinen asyncio HTTP-Listener auf Port 8090 (intern, nicht exposed). Brain pusht nach jedem Trigger-Feuer per `urllib.request.urlopen` an `http://aria-bridge:8090/internal/trigger-fired` mit `{reply, trigger_name, type, events}`. Bridge ruft `_handle_trigger_fired` → Side-Channel-Events (skill_created/trigger_created/location_tracking) + `_process_core_response` — exakt derselbe Pfad wie normale Chat-Antworten (Bubble + TTS + chat_backup)
- [x] **Tool-Use im Proxy durchgereicht** (claude-max-api-proxy): Der Proxy nahm das OpenAI-`tools`-Feld an, ignorierte es aber komplett — `openai-to-cli.js` wandelte nur `messages` zu einem String, `manager.js` rief `claude --print` ohne Tools. Claude Code nutzte ihre internen Tools (Bash, Read, ...) und „simulierte" Aktionen wie `sleep 120` statt `trigger_timer` zu rufen. Fix: zwei eigene Adapter-Files unter `proxy-patches/`, die zur Container-Startzeit ueber die npm-Version kopiert werden. `openai-to-cli.js` injiziert die `tools` als `<system>`-Block mit Schema-Beschreibungen und der Anweisung `<tool_call name="X">{json}</tool_call>` als Antwortformat zu verwenden; weiterhin verarbeitet sie `role=tool`-Messages als `<tool_result>`-Bloecke fuer den Loop-Replay. `cli-to-openai.js` parsed die `<tool_call>`-Bloecke aus dem Result-Text zurueck zu OpenAI `tool_calls` mit `finish_reason=tool_calls`. Mehrere Tool-Calls + Pre-Tool-Text werden korrekt aufgeteilt
- [x] **Timer "in 2 Minuten" wird wieder angelegt**: ARIA hatte keine Moeglichkeit die aktuelle Zeit zu kennen — kein Bash-Tool, kein Time-Tool, kein Timestamp im System-Prompt. Die Tool-Beschreibung von `trigger_timer` empfahl sogar `date -u -d '+10 minutes'` via Bash, aber Bash gab's nicht. Folge: LLM liess den Tool-Call entweder weg oder riet einen Cutoff-Zeitstempel (Vergangenheit) → Background-Loop feuerte beim naechsten 30s-Tick sofort statt in 2min. Fix: (1) `build_time_section()` in `prompts.py` injiziert UTC + lokale Europa/Berlin-Zeit als `## Aktuelle Zeit`-Block oben im System-Prompt. (2) `trigger_timer` akzeptiert jetzt `in_seconds` als Alternative zu `fires_at` — Server rechnet den absoluten Timestamp, ARIA muss nicht ISO-rechnen
@@ -280,6 +287,32 @@ Skills mit Tool-Use.
- [x] **Triggers-Block im System-Prompt**: aktive Trigger + verfuegbare Variablen + Funktionen werden bei jedem Chat-Turn injiziert, dazu Hinweis dass GPS-Watcher `request_location_tracking` mit-aufrufen sollen
- [x] **Aktuelle-Zeit-Block im System-Prompt**: UTC + lokale Europa/Berlin-Zeit (Sommer/Winter-Heuristik) wird bei jedem Chat-Turn oben mit-injiziert, damit Timer-fires_at und Watcher mit `hour_of_day` ueberhaupt sinnvoll sind. `trigger_timer` akzeptiert zusaetzlich `in_seconds` (Server rechnet) — ARIA muss bei relativen Angaben ('in 2 Minuten') nicht selbst ISO-rechnen
### Memory-System (Phase B Punkt 5+ Bonus)
- [x] **`memory_save`-Tool fuer ARIA**: ARIA kann selber neue Memories in die Qdrant-DB schreiben (vorher hat sie auf File-Memory ausweichen muessen weil kein Tool da war). Schema: `title`, `content`, `type` (identity/rule/preference/tool/skill/fact/conversation/reminder), optional `category`, `tags`, `pinned`. Tool-Description erklaert die Type-Wahl + sagt explizit „Du hast KEIN File-Memory mehr, schreibe nicht in `~/.claude/projects/...`". Side-Channel-Event `memory_saved` broadcastet via Bridge an App + Diagnostic — gelbe „🧠 ARIA hat etwas gemerkt"-Bubble, Auto-Refresh des Gehirn-Tabs falls offen
- [x] **Volltext-Suche im Gehirn** (`/memory/search-text`): Substring-Match (case-insensitive) ueber Title + Content + Category + Tags. Default in der Diagnostic-Suche, weil bei kleiner DB Semantic Search False-Positives ueberproduziert. Toggle „🧠 Semantisch" wechselt zu Embedder-Modus
- [x] **Advanced Search im Diagnostic-Gehirn-Tab**: aufklappbares Panel mit dynamisch erweiterbaren Suchfeldern (+ Feld Button) und UND/ODER-Operatoren zwischen ihnen. Backend-side bleibt simpel — pro Begriff einmal `/memory/search-text`, dann clientseitig per Set-Logik kombiniert. Pinned-/Type-Filter werden mit angewandt
- [x] **Mülltonne pro Chat-Bubble**: einzelne Nachrichten loeschbar (mit Confirm). Entfernt aus chat_backup.jsonl, Brain conversation.jsonl (rolling window) und allen Clients per RVS-Broadcast `chat_message_deleted`. Wichtig fuer ARIA: geloeschte Turns sind im naechsten Prompt nicht mehr im Window
- [x] **Druckansicht fuer Memories**: 📄-Button im Gehirn-Tab oeffnet eine fuer A4-Print optimierte Ansicht in neuem Tab — Strg+P → Als PDF speichern. Filter (Typ + Pinned) werden respektiert
- [x] **Gehirn-Kategorien standardmaessig eingeklappt**: Beim ersten Aufruf alle Type-Sections collapsed, Stefan klappt gezielt auf was er sehen will. State persistiert in localStorage
- [x] **Klappbare Type-Header + Category-AutoSuggest + Info-Modal**: Type-Header (▼/▶) klappbar, Category-Feld im Neu/Edit-Modal mit `<datalist>`-Vorschlaegen aller existierenden Categories, -Button-Modal erklaert welche Types FEST im System-Prompt vs. Cold Memory sind
### Memory-Anhaenge mit Vision (Stufe A-E + attach_paths)
- [x] **Anhaenge an Memory-Eintraege** — Bilder/PDFs/beliebige Dateien koennen an jede Memory gehaengt werden, liegen physisch unter `/shared/memory-attachments/<memory-id>/`. Cleanup beim Memory-Delete automatisch. Limit 20 MB pro Datei
- [x] **Backend-Endpoints**: GET/POST/DELETE `/memory/{id}/attachments[/...]`, plus Multipart-Upload-Variante `/upload` fuer Browser-FormData (Base64-Upload sprengt bei grossen Files Bash's ARG_MAX, multipart ist sauberer). Diagnostic-Proxy mit dynamischem Timeout (120s fuer /attachments, 60s sonst)
- [x] **Diagnostic-UI**: Memory-Modal hat Upload-Block (multiple File-Picker), Thumbnail-Vorschau bei Bildern + 📄-Icon bei Files, Klick auf Bild → Lightbox, 🗑 pro Anhang. Memory-Liste zeigt 📎N-Badge wenn N > 0 Anhaenge
- [x] **App-UI**: `memory_saved`-Bubble zeigt Anhaenge als Tap-Reihen. Tap → `file_request` ueber RVS → Bridge laedt + bei Bildern Vollbild-Modal, bei anderen Intent-Picker. `file_response`-Handler matched zusaetzlich `memorySaved.attachments[].path`
- [x] **System-Prompt-Integration**: `_attachments_line` in `prompts.py` haengt nach Hot/Cold-Memory-Eintraegen eine `📎 Anhaenge: foo.jpg (...) — Pfad: ...`-Zeile an. Bei `image/*` zusaetzlich Hinweis „Bilder kannst du via `Read <pfad>` direkt ansehen — Claude Code Read ist multi-modal-faehig"
- [x] **ARIA sieht Bilder echt** — Stufe E ohne Proxy-Patch: Claude Code's `Read`-Tool ist bereits multi-modal. ARIA ruft `Read /shared/memory-attachments/<id>/foto.jpg` → Vision-Modell beschreibt das Bild, ARIA antwortet mit den extrahierten Infos. End-to-End getestet mit Cessna-Foto: ARIA hat D-ECSW-Kennung aus dem Bild gelesen, F172-Variante erkannt (Reims-Aviation), EDWM-ICAO fuer Mariensiel selbst dazu kombiniert. **Persistent**: Bild bleibt am Memory, bei spaeteren Detail-Fragen („wie viele Fenster?") kann ARIA das Bild nochmal lesen ohne dass User es re-uploaden muss
- [x] **`memory_save` mit `attach_paths`** — ARIA kann beim Speichern selber Bilder anhaengen. Pfade aus `/shared/uploads/` (z.B. ein User-Foto aus dem Chat) werden serverseitig nach `/shared/memory-attachments/<id>/` kopiert. Pfadschutz auf Whitelist-Prefixes (kein Root-FS-Zugriff). Tool-Description weist explizit an: erst `Read <pfad>` (Vision-Beschreibung), dann `memory_save(content=<extrahierte Infos>, attach_paths=[<pfad>])` — End-to-End-Workflow in einer Tool-Call-Sequenz
### DB als Single Source of Truth
- [x] **`brain-import/` als Drop-Folder** statt aktive Saat: Inhalt komplett gitignored, nur `.gitkeep` + README im Repo. Stefan kippt MDs rein wenn er was migrieren will, klickt im Diagnostic „Migration aus brain-import/", fertig. Alte AGENT.md/BOOTSTRAP.md aus dem Repo geworfen (waren teils OpenClaw-Altlasten)
- [x] **DB-Aufraeumung**: 60 → 31 Eintraege durch Loeschen von 24 Dubletten (gleicher Title+Content unter verschiedenen IDs aus der initialen Migration) + 6 obsoleten facts (OpenClaw-Geschichte, Home-Partition-Snapshots etc.). Firmenadresse als einzige aktive `fact` behalten
- [x] **`.claude/aria-vm.env` Setup** fuer die Dev-Maschine: Claude Code auf Stefans Workstation erreicht das Brain-API ueber Diagnostic-Port 3001 via `ARIA_BRAIN_URL`. `.example` im Repo, echte Datei mit IP der VM gitignored. Damit kann Claude direkt curl gegen die DB machen ohne SSH-Tunnel
### Diagnostic / App Features (drumherum)
- [x] Datei-Manager (Diagnostic + App-Modal): /shared/uploads/ verwalten, Multi-Select + Select-All + Bulk-Download als ZIP + Bulk-Delete
+1
View File
@@ -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",