Compare commits

..

4 Commits

Author SHA1 Message Date
duffyduck 474e2c6c50 feat(brain): Skill-Regeln als seed_rules — idempotent auf Brain-Boot in DB
Stefans Skill-Friedhof (9 Spotify-Skills, hardcoded Credentials) hatte
keine systemische Ursache im Code, sondern im fehlenden Leitplanken-
Memory. Lösung: System-Seed-Regeln als pinned Hot Memory, mit jedem
Deploy ausgerollt.

- aria-brain/seed_rules.py: 5 rule-type Memories (skill_list-vor-create,
  no-version-suffix, update-not-recreate, no-hardcoded-credentials,
  config-schema-for-settings), source="seed", pinned=true
- Lifespan ruft seed_rules.apply() beim Brain-Start — idempotent via
  migration_key (alte Versionen werden vor dem Schreiben gelöscht)
- skill_create Tool-Description um PFLICHT-VORHER-Block ergänzt:
  skill_list-check, kein Versionssuffix, oauth_get_token bei OAuth,
  config_schema statt hardcoded Werte

Editieren = SEED_RULES-Liste anpassen, Brain neu starten. Im Gegensatz
zu brain-import/ (User-Saatgut, gitignored, manueller Diagnostic-Klick)
gehört das hier zum Brain-Code und rollt mit jedem Deploy aus.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 22:55:05 +02:00
duffyduck 3e0cfef63c changed docker compose rvs to 444 2026-05-25 10:31:27 +02:00
duffyduck b94626787b fix(diagnostic): chat_history-Render verträgt kaputte Bubbles + EHOSTUNREACH skipped TLS-Fallback
Zwei kleine Robustness-Verbesserungen:

1) chat_history-Handler im Frontend: jede Bubble jetzt in try/catch. Wenn
   eine Bubble bei der Render-Pipeline (escape/linkify/regex-replace) eine
   Exception wirft, brach die ganze for-Schleife ab und alle nachfolgenden
   Bubbles wurden nicht mehr in den DOM geschrieben — beim Reload sah man
   dann nur die ersten N Eintraege und Stefan dachte die letzten Antworten
   waeren weg. Jetzt: Fehler-Bubble mit "⚠ Render-Fehler" + console.error,
   restliche Bubbles laufen weiter durch.

2) Diagnostic-Server RVS-Reconnect: TLS-Fallback war auch bei reinen
   Netz-Fehlern (EHOSTUNREACH, ECONNREFUSED, ENETUNREACH, ETIMEDOUT,
   ENOTFOUND, EAI_AGAIN) gefeuert — bringt nichts weil der Server eh tot
   ist, generiert aber doppelte Reconnect-Versuche + Log-Spam. Jetzt nur
   noch bei wirklichen TLS/Handshake-Fehlern.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 10:28:57 +02:00
duffyduck ad87c807de fix(app): App-Reconnect nach Hintergrund — Sticky-Fallback, Zombie-WS, AppState-Hook
Stefan musste seit der HTTPS-Umstellung nach jedem Hintergrund-Rueckkehr
manuell auf "Verbinden" tippen, meist 3x bis es ging. Gleiche Bug-Klasse
wie auf der Bridge davor (Sticky-Fallback), plus zwei App-spezifische
Symptome.

Drei Ursachen:

1. usingTLSFallback klebt: einmal nach onerror auf true gesetzt, blieb
   es bei allen folgenden Reconnects → App versuchte ws://...:443 gegen
   den TLS-only Caddy → HTTP 400 → endlos. Reset war NUR im manuellen
   connect(), nicht in onclose oder scheduleReconnect.
   Fix: in onclose `usingTLSFallback = false` damit der naechste
   Reconnect wieder primary (wss://) probiert.

2. Zombie-WebSocket: Android kann den TCP-Socket im Background still
   killen, der JS-State zeigt aber noch readyState === OPEN. Stefans
   manueller "Verbinden"-Klick rief connect() → "Bereits verbunden"
   No-Op statt sich neu aufzubauen.
   Fix: connect(force=true) optional, bestehendes WS-Objekt wird hart
   geschlossen (mit onclose=null gegen Doppel-Reconnect) bevor neuer
   Aufbau startet.

3. Keine aktive Reconnect-Sequence bei Foreground-Resume: App war
   abhaengig von onclose-Events die bei Zombie-WS nicht zwingend
   feuern.
   Fix: AppState-Listener in App.tsx, bei background → active
   automatischer rvs.connect(true).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 10:09:30 +02:00
8 changed files with 274 additions and 33 deletions
+19 -1
View File
@@ -6,7 +6,7 @@
*/
import React, { useEffect } from 'react';
import { PermissionsAndroid, Platform, StatusBar, StyleSheet } from 'react-native';
import { AppState, AppStateStatus, PermissionsAndroid, Platform, StatusBar, StyleSheet } from 'react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';
import { NavigationContainer, DefaultTheme } from '@react-navigation/native';
import { createBottomTabNavigator } from '@react-navigation/bottom-tabs';
@@ -107,8 +107,26 @@ const App: React.FC = () => {
console.warn('[App] GPS-Tracking restore fehlgeschlagen:', err?.message || err);
});
// AppState-Listener: nach Hintergrund-Rueckkehr aktiv die WS-
// Verbindung neu aufbauen. Hintergrund: Android kann den TCP-Socket
// im Background killen, JS-State zeigt aber noch OPEN → Stefan musste
// manuell in Settings auf "Verbinden" tippen, oft mehrfach. Mit dem
// force-Reconnect bei "active" greift das automatisch.
let lastAppState: AppStateStatus = AppState.currentState;
const appStateSub = AppState.addEventListener('change', (next) => {
const wasBg = lastAppState !== 'active';
lastAppState = next;
if (next === 'active' && wasBg) {
console.log('[App] Foreground-Resume — force-reconnect zum RVS');
try { rvs.connect(true); } catch (e: any) {
console.warn('[App] force-reconnect fehlgeschlagen:', e?.message || e);
}
}
});
// Beim Beenden: Verbindung sauber trennen
return () => {
appStateSub.remove();
rvs.disconnect();
};
}, []);
+31 -3
View File
@@ -83,21 +83,39 @@ class RVSConnection {
// --- Verbindung ---
/** Verbindung zum RVS aufbauen */
connect(): void {
/** Verbindung zum RVS aufbauen. force=true: bestehende Connection hart
* schliessen + neu verbinden (auch wenn JS denkt readyState=OPEN — kann
* nach Hintergrund-Pause ein Zombie-WS sein wo TCP tot ist aber JS-State
* noch OPEN zeigt; in dem Fall war "Bereits verbunden" ein No-Op und
* Stefan musste manuell zigmal klicken). */
connect(force: boolean = false): void {
if (!this.config) {
this.log('warn', 'Keine Verbindungskonfiguration vorhanden');
return;
}
if (this.ws?.readyState === WebSocket.OPEN) {
if (!force && this.ws?.readyState === WebSocket.OPEN) {
this.log('info', 'Bereits verbunden');
return;
}
// Wenn ein WS-Objekt da ist (Zombie oder lebend), sauber abreissen
// bevor wir einen neuen aufbauen — sonst gibt's zwei parallele
// Verbindungen + doppelte Events.
if (this.ws) {
this.log('info', 'Bestehende WS-Verbindung wird geschlossen vor Neu-Connect');
try {
this.ws.onclose = null; // verhindert dass scheduleReconnect doppelt feuert
this.ws.onerror = null;
this.ws.close();
} catch (_) {}
this.ws = null;
}
this.shouldReconnect = true;
this.reconnectDelay = INITIAL_RECONNECT_DELAY_MS;
this.usingTLSFallback = false;
this.clearTimers();
this.log('info', `Verbindungsaufbau zu ${this.config.host}:${this.config.port} (TLS: ${this.config.useTLS ? 'ja' : 'nein'})`);
this.establishConnection();
}
@@ -212,6 +230,16 @@ class RVSConnection {
this.ws = null;
this.setState('disconnected');
// Sticky-Fallback-Reset: beim naechsten Reconnect wieder primary
// (wss://) versuchen statt fuer immer auf ws:// zu kleben. War
// der Hauptgrund warum die App nach Hintergrund-Rueckkehr nicht
// mehr verband — TLS-Handshake-Timeout in einem Reconnect → Fallback
// auf ws:// → Caddy refused → endlos im Fallback haengen.
if (this.usingTLSFallback) {
this.log('info', 'Reset TLS-Fallback fuer naechsten Reconnect (zurueck zu wss://)');
this.usingTLSFallback = false;
}
if (this.shouldReconnect) {
this.scheduleReconnect();
}
+12
View File
@@ -72,6 +72,18 @@ META_TOOLS = [
"Erstelle einen neuen Skill (wiederverwendbare Faehigkeit). "
"Skills sind IMMER Python — jeder Skill bekommt seine eigene venv "
"mit den pip_packages die er braucht.\n\n"
"PFLICHT VORHER:\n"
" - `skill_list` aufrufen und pruefen ob ein passender Skill schon "
"existiert. Wenn ja: `skill_update` statt neu anlegen.\n"
" - Name OHNE Versionssuffix waehlen (kein `-v2`, `_v3`, `-new`, "
"`-fixed`, `-aria`, `-ctl`). Versionsverwaltung ist intern, Du brauchst "
"nur einen klaren Namen.\n"
" - Bei OAuth-Services (Spotify, Google, GitHub etc.): NIEMALS "
"client_id/client_secret/Tokens in den Code schreiben. Nutze "
"`oauth_get_token('<service>')` — das macht Auto-Refresh. Sonst muss "
"Stefan sich alle 60min manuell neu einloggen.\n"
" - Bei konfigurierbaren Werten (User-IDs, Endpoints, Defaults): "
"ueber `config_schema` deklarieren, NICHT hardcoden.\n\n"
"HARTE REGEL — IMMER Skill anlegen wenn: die Loesung erfordert eine "
"pip-Library. Sonst muesste der Install bei jedem Container-Restart "
"neu laufen (Brain hat keinen persistenten State ausser /data/skills/).\n\n"
+8 -1
View File
@@ -37,6 +37,7 @@ import triggers as triggers_mod
import watcher as watcher_mod
import background as background_mod
import oauth as oauth_mod
import seed_rules as seed_rules_mod
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s")
logger = logging.getLogger("aria-brain")
@@ -46,7 +47,13 @@ QDRANT_PORT = int(os.environ.get("QDRANT_PORT", "6333"))
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Beim Brain-Start: Trigger-Background-Loop anwerfen. Beim Shutdown: stoppen."""
"""Beim Brain-Start: System-Seed-Regeln idempotent in DB schreiben,
Trigger-Background-Loop anwerfen. Beim Shutdown: Loop stoppen."""
try:
result = seed_rules_mod.apply(store(), embedder())
logger.info("Lifespan: seed_rules angewendet (%s)", result)
except Exception as exc:
logger.exception("Lifespan: seed_rules fehlgeschlagen — Brain startet trotzdem (%s)", exc)
task = asyncio.create_task(background_mod.run_loop(agent))
logger.info("Lifespan: Trigger-Loop gestartet")
try:
+150
View File
@@ -0,0 +1,150 @@
"""
System-Seed-Regeln — werden bei jedem Brain-Boot idempotent in die
Vector-DB geschrieben (pinned, source="seed").
Im Gegensatz zu aria-data/brain-import/ (User-Saatgut, manuell via
Diagnostic-Klick migriert) ist das hier System-Regeln, die zum Brain-Code
gehoeren und mit jedem Deploy ausgerollt werden.
Idempotenz: Punkte mit gleicher `migration_key` werden vor dem Schreiben
geloescht. Editieren = Zeile aendern, Brain neu starten, fertig.
"""
from __future__ import annotations
import logging
import uuid
from datetime import datetime, timezone
from typing import List
from memory import Embedder, VectorStore
from memory.vector_store import COLLECTION
from qdrant_client.http import models as qm
logger = logging.getLogger(__name__)
# Jede Regel = ein eigener Memory-Punkt. Klein halten, klar formulieren —
# ARIA sieht das in jedem Chat-Turn als pinned Hot Memory.
SEED_RULES: List[dict] = [
{
"migration_key": "seed/skill-rule/list-before-create",
"type": "rule",
"title": "Skill-Regel: skill_list vor skill_create",
"category": "skills",
"content": (
"Bevor du einen neuen Skill mit `skill_create` anlegst, ruf IMMER "
"zuerst `skill_list` auf. Schau dir die Namen und Descriptions an. "
"Wenn ein passender Skill existiert: verwende ihn oder verbessere "
"ihn mit `skill_update`. Lege keinen Duplikat-Skill an."
),
},
{
"migration_key": "seed/skill-rule/no-version-suffix",
"type": "rule",
"title": "Skill-Regel: keine Versions-Suffixe im Namen",
"category": "skills",
"content": (
"Skill-Namen muessen permanent und beschreibend sein. NIEMALS "
"Suffixe wie `-v2`, `_v3`, `-new`, `-fixed`, `-aria`, `-ctl` "
"anhaengen, um eine neue Variante zu bauen. Wenn ein Skill kaputt "
"ist oder verbessert werden soll: `skill_update`. Versionsverwaltung "
"macht das System intern (Rollback ueber `skill_rollback`)."
),
},
{
"migration_key": "seed/skill-rule/update-not-recreate",
"type": "rule",
"title": "Skill-Regel: kaputten Skill reparieren, nicht neu bauen",
"category": "skills",
"content": (
"Wenn ein vorhandener Skill nicht wie erwartet funktioniert, lies "
"zuerst Code + Logs (`skill_get`, `skill_logs`). Repariere ihn dann "
"mit `skill_update` (entry_code, readme oder pip_packages patchen). "
"Baue NIEMALS einen zweiten Skill mit aehnlichem Namen — das gibt "
"Skill-Friedhof und Stefan muss aufraeumen."
),
},
{
"migration_key": "seed/skill-rule/no-hardcoded-credentials",
"type": "rule",
"title": "Skill-Regel: keine hardcoded Credentials",
"category": "skills",
"content": (
"Schreibe NIEMALS API-Keys, Tokens, Passwoerter, client_id oder "
"client_secret direkt in den Skill-Code. Fuer OAuth-Services "
"(Spotify, Google, GitHub etc.) nutze das Brain-Tool "
"`oauth_get_token('<service>')` — das macht Auto-Refresh und "
"haelt den Token frisch. Stefan muss sich sonst alle 60 Minuten "
"manuell neu einloggen, das nervt."
),
},
{
"migration_key": "seed/skill-rule/config-schema-for-settings",
"type": "rule",
"title": "Skill-Regel: konfigurierbare Werte ueber config_schema",
"category": "skills",
"content": (
"Wenn dein Skill konfigurierbare Werte braucht (User-IDs, "
"Default-Geraete, Endpoints, nicht-OAuth-API-Keys), deklariere "
"sie im `config_schema`-Feld der skill.json. Stefan setzt sie "
"dann in der Diagnostic-UI; der Skill bekommt die Werte zur "
"Laufzeit als Environment-Variable `CFG_<NAME>`. NICHT als "
"Argument, NICHT hardcoded."
),
},
]
def apply(store: VectorStore, embedder: Embedder) -> dict:
"""Schreibt alle SEED_RULES idempotent in die DB.
Vorgehen: erst alle Punkte mit `source=seed` UND passender migration_key
loeschen, dann frisch upserten. So koennen Regeln editiert/entfernt
werden indem die SEED_RULES-Liste angepasst wird.
"""
if not SEED_RULES:
return {"written": 0}
migration_keys = [r["migration_key"] for r in SEED_RULES]
# Alte Versionen entfernen (nur die mit unserer migration_key — andere
# source=seed Punkte aus zukuenftigen seed-Files sind sicher)
try:
store.client.delete(
collection_name=COLLECTION,
points_selector=qm.FilterSelector(filter=qm.Filter(must=[
qm.FieldCondition(key="migration_key", match=qm.MatchAny(any=migration_keys))
])),
)
except Exception as exc:
logger.warning("seed_rules: delete-by-migration_key fehlgeschlagen (%s) — wahrscheinlich erster Run", exc)
# Frisch einbetten + schreiben
texts = [r["content"] for r in SEED_RULES]
vectors = embedder.embed_batch(texts)
now = datetime.now(timezone.utc).isoformat()
written = 0
for rule, vec in zip(SEED_RULES, vectors):
payload = {
"type": rule["type"],
"title": rule["title"],
"content": rule["content"],
"pinned": True,
"category": rule.get("category", ""),
"source": "seed",
"tags": [],
"created_at": now,
"updated_at": now,
"migration_key": rule["migration_key"],
"attachments": [],
}
store.client.upsert(
collection_name=COLLECTION,
points=[qm.PointStruct(id=str(uuid.uuid4()), vector=vec, payload=payload)],
)
written += 1
logger.info("seed_rules: %d Regeln in DB geschrieben", written)
return {"written": written, "keys": migration_keys}
+43 -25
View File
@@ -1650,36 +1650,54 @@
if (msg.type === 'chat_history') {
const boxes = [chatBox, document.getElementById('chat-box-fs')].filter(Boolean);
for (const b of boxes) b.innerHTML = '';
let errorCount = 0;
if (msg.messages && msg.messages.length > 0) {
for (const m of msg.messages) {
if (m.type === 'aria_file') {
// ARIA-Datei-Bubble — addAriaFile schreibt selbst in beide Boxen
addAriaFile({ serverPath: m.serverPath, name: m.name, mimeType: m.mimeType, size: m.size, deleted: m.deleted });
continue;
}
// [FILE: ...]-Marker rausfiltern (gleicher Filter wie addChat)
const cleaned = (m.text || '').replace(/\[FILE:\s*\/shared\/uploads\/[^\]]+\]/gi, '').replace(/\n{3,}/g, '\n\n').trim();
const escaped = escapeHtml(cleaned);
let linked = linkifyText(escaped);
// /shared/uploads/-Bildpfade auch im History inline rendern
linked = linked.replace(/\/shared\/uploads\/[^\s<"]+\.(jpg|jpeg|png|gif|webp|svg|bmp)/gi, (match) => {
return `<a href="${match}" target="_blank">${match}</a><img src="${match}" class="chat-media" onclick="openLightbox('image','${match}')" onerror="this.style.display='none'">`;
});
const time = m.ts ? new Date(m.ts).toLocaleTimeString('de-DE') : '?';
const trashBtn = m.ts
? `<button class="bubble-trash" title="Diese Bubble loeschen" onclick="deleteDiagBubble(${m.ts})">🗑</button>`
: '';
const innerHtml = `${trashBtn}${linked}<div class="meta">${escapeHtml(m.meta)} — ${time}</div>`;
for (const b of boxes) {
const el = document.createElement('div');
el.className = `chat-msg ${m.type}`;
if (m.ts) el.dataset.ts = String(m.ts);
el.innerHTML = innerHtml;
b.appendChild(el);
for (let mi = 0; mi < msg.messages.length; mi++) {
const m = msg.messages[mi];
try {
if (m.type === 'aria_file') {
addAriaFile({ serverPath: m.serverPath, name: m.name, mimeType: m.mimeType, size: m.size, deleted: m.deleted });
continue;
}
const cleaned = (m.text || '').replace(/\[FILE:\s*\/shared\/uploads\/[^\]]+\]/gi, '').replace(/\n{3,}/g, '\n\n').trim();
const escaped = escapeHtml(cleaned);
let linked = linkifyText(escaped);
linked = linked.replace(/\/shared\/uploads\/[^\s<"]+\.(jpg|jpeg|png|gif|webp|svg|bmp)/gi, (match) => {
return `<a href="${match}" target="_blank">${match}</a><img src="${match}" class="chat-media" onclick="openLightbox('image','${match}')" onerror="this.style.display='none'">`;
});
const time = m.ts ? new Date(m.ts).toLocaleTimeString('de-DE') : '?';
const trashBtn = m.ts
? `<button class="bubble-trash" title="Diese Bubble loeschen" onclick="deleteDiagBubble(${m.ts})">🗑</button>`
: '';
const innerHtml = `${trashBtn}${linked}<div class="meta">${escapeHtml(m.meta)} — ${time}</div>`;
for (const b of boxes) {
const el = document.createElement('div');
el.className = `chat-msg ${m.type}`;
if (m.ts) el.dataset.ts = String(m.ts);
el.innerHTML = innerHtml;
b.appendChild(el);
}
} catch (renderErr) {
// Eine kaputte Bubble darf nicht den Rest der History killen.
// Vorher passierte genau das: Frontend-Render bracht bei einer
// problematischen Antwort ab, alle nachfolgenden Nachrichten waren
// beim Reload weg. Jetzt: Fehler-Bubble einbauen + weitermachen.
errorCount++;
console.error('chat_history render error at idx ' + mi + ':', renderErr, m);
for (const b of boxes) {
const el = document.createElement('div');
el.className = `chat-msg ${m.type || 'received'}`;
if (m.ts) el.dataset.ts = String(m.ts);
el.innerHTML = `<span style="color:#FF6B6B;">⚠ Render-Fehler in Bubble (${escapeHtml(String(renderErr.message || renderErr))})</span><div class="meta">${m.ts ? new Date(m.ts).toLocaleTimeString('de-DE') : '?'}</div>`;
b.appendChild(el);
}
}
}
for (const b of boxes) b.scrollTop = b.scrollHeight;
}
if (errorCount > 0) {
console.warn(`chat_history: ${errorCount} Bubble(s) konnten nicht gerendert werden`);
}
return;
}
+10 -2
View File
@@ -701,8 +701,16 @@ function connectRVS(forcePlain) {
state.rvs.lastError = err.message;
broadcastState();
// TLS Fallback
if (useTls && RVS_TLS_FALLBACK === "true" && !fallbackTriggered) {
// TLS-Fallback nur bei wirklichen TLS/Handshake-Fehlern.
// Bei Netz-Problemen wie EHOSTUNREACH, ECONNREFUSED, ENETUNREACH,
// EAI_AGAIN ist der Server eh tot — Fallback bringt nichts ausser
// Log-Spam und doppelten Retries.
const netErr = (err.code || err.message || "").toString();
const isNetDown =
/^(EHOSTUNREACH|ECONNREFUSED|ENETUNREACH|ETIMEDOUT|EAI_AGAIN|ENOTFOUND)$/.test(netErr) ||
/EHOSTUNREACH|ECONNREFUSED|ENETUNREACH|ETIMEDOUT|EAI_AGAIN|ENOTFOUND/.test(err.message || "");
if (useTls && RVS_TLS_FALLBACK === "true" && !fallbackTriggered && !isNetDown) {
fallbackTriggered = true;
log("warn", "rvs", "TLS fehlgeschlagen — Fallback auf ws://");
try { ws.removeAllListeners(); ws.close(); } catch (_) {}
+1 -1
View File
@@ -39,7 +39,7 @@ services:
restart: always
ports:
- "80:80"
- "443:443"
- "444:443"
command: caddy reverse-proxy --from ${PUBLIC_URL} --to rvs:3000
volumes:
- ./data/caddy/data:/data # Zertifikate (PERSISTENT)