Compare commits

..

7 Commits

Author SHA1 Message Date
duffyduck 6821eaaa38 release: bump version to 0.1.2.5 2026-05-12 00:40:25 +02:00
duffyduck 31aa86a2a9 feat(brain+ui+app): Triggers — passive Aufweck-Quellen fuer ARIA
ARIA hatte bisher nur ein "User fragt → Brain antwortet"-Modell. Neu:
Trigger laufen passiv im Hintergrund (kein LLM-Call) und wecken ARIA
nur dann auf wenn ein Event tatsaechlich passiert.

Drei Typen, zwei aktuell implementiert:
  timer   — einmalig zu festem ISO-Timestamp ("erinner mich in 10min")
  watcher — Polling alle N Sek einer Condition, feuert bei True mit Throttle
            (z.B. "disk_free_gb < 5", max 1x/h)
  cron    — Platzhalter fuer spaeter

aria-brain/triggers.py
  CRUD auf /data/triggers/<name>.json + /data/triggers/logs/<name>.jsonl.
  create_timer, create_watcher, mark_fired, list_logs, etc.

aria-brain/watcher.py
  Built-in Condition-Variablen: disk_free_gb, disk_free_pct, uptime_sec,
  hour_of_day, day_of_week, rvs_connected, memory_count.
  Sicherer Condition-Parser via ast — Whitelist auf Vergleich + BoolOp +
  Name + Const. Kein eval, kein exec, keine Builtins.

aria-brain/background.py
  Async Loop laeuft alle 30s, sammelt einmalig Variables, geht durch
  Trigger-Liste, _should_fire-Check (Timer: fires_at vergangen / Watcher:
  check_interval + throttle respektiert + condition true). Fire ruft
  agent.chat(prompt, source="trigger") — ARIA bekommt das wie eine
  Push-Nachricht und antwortet via Bridge → RVS → App.

aria-brain/main.py
  /triggers/list, /{name}, /{name}/logs, /timer, /watcher, PATCH, DELETE,
  /triggers/conditions (Variablen + aktuelle Werte). Lifespan-Handler
  startet den Background-Loop beim Container-Start, stoppt beim Shutdown.

aria-brain/agent.py
  Meta-Tools fuer ARIA: trigger_timer, trigger_watcher, trigger_cancel,
  trigger_list. ARIA legt Trigger via Tool-Call selbst an wenn Stefan das
  wuenscht. Side-Channel-Event 'trigger_created' wird in chat-Response
  mitgeschickt damit App + Diagnostic eine Bubble zeigen.

aria-brain/prompts.py
  Neue System-Prompt-Section: Liste aktiver Triggers + verfuegbare
  Condition-Variablen mit aktuellen Werten + Operatoren-Erklaerung.
  ARIA weiss damit immer was es schon gibt und welche Vars sie nutzen kann.

bridge/aria_bridge.py + rvs/server.js
  trigger_created als neuer RVS-Message-Type, Bridge forwarded das aus
  data.events analog zu skill_created.

diagnostic/index.html
  Neuer Top-Tab "Trigger". Liste mit Type-Badges (⏱ TIMER / 👁 WATCHER),
  Status, Fire-Count, last_fired. Aktivieren/Deaktivieren + Löschen pro
  Trigger. "+ Neu"-Modal mit Type-Dropdown, Timer-Minuten oder
  Watcher-Condition + Vars-Anzeige + Throttle. Info-Modal-Eintrag mit
  Erklaerung. Live-Bubble im Chat wenn ARIA selbst einen anlegt.

android/src/screens/ChatScreen.tsx
  trigger_created RVS-Handler → eigene Bubble (gelber Border, " ARIA
  hat einen Trigger angelegt", Type/Detail/Message/Zeit). ChatMessage
  bekam triggerCreated-Feld. Lokal-only-Schutz beim Server-Sync analog
  zu skill_created.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 00:38:58 +02:00
duffyduck 87cb687610 fix(diagnostic): ARIA-Textantworten landen jetzt im Chat (Gateway-Dedup raus)
Symptom: Diagnostic-Chat zeigt nur ARIA-Dateien (file_from_aria), Text-
Antworten kamen nicht an. STT-Eintraege + User-Messages waren sichtbar.

Ursache: Im rvs_chat-Handler stand
  if (sender === 'aria') return;
Die alte Begruendung war "ARIA-Antworten kommen schon via Gateway (chat:final)".
Das galt zu OpenClaw-Zeit, wo Diagnostic eine direkte WS zum aria-core hatte.
Gateway ist seit dem Abriss weg, ARIA-Antworten kommen jetzt ausschliesslich
via RVS → der return blockte sie still.

Fix: chatType + label je nach sender:
  - aria  → received-Bubble, Label "ARIA"
  - stt   → sent-Bubble, Label "🎤 Spracheingabe" (wie vorher)
  - sonst → sent-Bubble, Label "via RVS (<sender>)"

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 00:21:10 +02:00
duffyduck eb4059a887 fix: 3 Bugs — agent_activity haengt, Such-Scroll, STT-Bubble-Timing
Bug 1: "ARIA denkt..." in der App bleibt stehen
  _process_core_response setzte am Ende kein idle — die alten Aufrufe waren
  in der OpenClaw-WS-Loop, in der Brain-HTTP-Variante fehlten sie. Plus
  send_to_core schickte agent_activity direkt via _send_to_rvs ohne den
  _last_activity_state-Cache zu pflegen → _emit_activity("idle") wurde
  spaeter dedupliziert.
  Fix:
    - _emit_activity statt direktem _send_to_rvs fuer thinking
    - _emit_activity("idle") am Ende von _process_core_response
    - _last_chat_final_at bewusst NICHT setzen — die 3s-Cooldown war fuer
      trailing OpenClaw-Events, wuerde bei Voice die naechste thinking-Welle
      unterdruecken

Bug 2: App Chat-Suche scrollt nicht zur Stelle
  scrollToIndex wurde zu fruh aufgerufen (Layout noch nicht fertig) und
  viewPosition: 0.4 in inverted-FlatList war ungenau.
  Fix:
    - requestAnimationFrame um den Scroll-Aufruf
    - viewPosition: 0.5 (mittig)
    - onScrollToIndexFailed: erst grob scrollen via averageItemLength,
      dann nach 250ms praeziser nachfassen

Bug 3: Voice-Bubble bekommt STT-Text erst mit ARIA-Antwort
  _process_app_audio rief erst send_to_core (blockt synchron auf Brain,
  kann 300s dauern), DANN STT-Broadcast. App sah den eigenen Text erst
  wenn ARIA fertig war.
  Fix: Reihenfolge getauscht — STT-Broadcast zuerst, dann send_to_core.
  Voice-Bubble bekommt jetzt den erkannten Text sofort.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 00:17:10 +02:00
duffyduck 415706036b release: bump version to 0.1.2.4 2026-05-11 23:57:16 +02:00
duffyduck e2dd47255e docs: README + issue.md — App-Chat-Sync-Verhalten praezisiert
App-Chat-Sync ist seit Commit 3497aa2 "Server is Source of Truth" — bei
jedem Reconnect KOMPLETTER Server-Stand statt incremental. Doku angepasst:

  - App leert sich wenn Server leer ist (z.B. nach "Konversation zuruecksetzen")
  - Lokal-only Bubbles bleiben erhalten (Skill-Notifications, Voice ohne STT)
  - Bridge schreibt chat_backup.jsonl pro Turn — als Server-Backing-Store

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 23:56:14 +02:00
duffyduck 3497aa23f8 fix(app): kompletter Server-Sync bei Reconnect — Server ist Source of Truth
Symptom: Diagnostic-Server hat leere Chat-History (z.B. nach "Konversation
zuruecksetzen" oder Wipe), App zeigt aber weiterhin ihren alten lokalen
Stand. Wer das Wipe-Event verpasst hat (App offline), bleibt veraltet.

Ursache: App schickte beim Reconnect chat_history_request {since: lastSync}
und ignorierte leere Antworten. Wenn der Server ueberhaupt nichts mehr hat
liefert er korrekt [] zurueck — App behielt aber lokalen State.

Fix:
  - App schickt jetzt {since: 0, limit: 200} → KOMPLETTER Server-Stand
  - Handler ersetzt die persistierte Chat-History mit dem Server-Stand
    (statt zu mergen)
  - Lokal-only Bubbles bleiben erhalten:
      * Skill-Created-Notifications (skillCreated gesetzt)
      * Laufende Sprachnachrichten ohne STT-Result (audioRequestId gesetzt
        und text leer/Placeholder)
  - Wenn Server leer: lastSync ebenfalls geloescht (sauberer Restart-State)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 23:55:25 +02:00
14 changed files with 1283 additions and 65 deletions
+1 -1
View File
@@ -860,7 +860,7 @@ docker exec aria-brain curl localhost:8080/memory/stats
- [x] **Phase B Punkt 3:** Brain Conversation-Loop (Single-Chat UI, Rolling Window 50 Turns, Schwelle 60 → automatisches Destillat, manueller Trigger)
- [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] Sprachmodell-Setting wieder funktional (brainModel in runtime.json statt aria-core)
- [x] App-Chat-Sync: verpasste Nachrichten beim Reconnect + chat_cleared Live-Update
- [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
- [x] Token/Call-Metrics + Subscription-Quota-Tracking (Pro / Max 5x / Max 20x / Custom)
- [x] Datei-Manager Multi-Select: Bulk-Download als ZIP + Bulk-Delete (Diagnostic + App)
+2 -2
View File
@@ -79,8 +79,8 @@ android {
applicationId "com.ariacockpit"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 10203
versionName "0.1.2.3"
versionCode 10205
versionName "0.1.2.5"
// 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.3",
"version": "0.1.2.5",
"private": true,
"scripts": {
"android": "react-native run-android",
+101 -25
View File
@@ -79,6 +79,14 @@ interface ChatMessage {
active: boolean;
setupError?: string;
};
/** Trigger-Created-Bubble: ARIA hat einen neuen Trigger angelegt */
triggerCreated?: {
name: string;
type: 'timer' | 'watcher' | string;
message: string;
fires_at?: string;
condition?: string;
};
}
// --- Konstanten ---
@@ -407,15 +415,17 @@ const ChatScreen: React.FC = () => {
return;
}
// chat_history_response: verpasste Nachrichten nachladen (bei Reconnect)
// chat_history_response: kompletter Server-Stand. App ersetzt ihre
// persistierte Chat-History damit. Lokal-only Bubbles (laufende
// Voice-Aufnahmen ohne STT-Result, Skill-Created-Events ohne
// text) bleiben erhalten — die sind durch fehlendes 'text' oder
// skillCreated/audioRequestId klar als "lokal" erkennbar.
if (message.type === 'chat_history_response') {
const p = (message.payload || {}) as any;
const incoming = (p.messages || []) as Array<any>;
if (!incoming.length) return;
console.log(`[Chat] ${incoming.length} verpasste Nachrichten nachgeladen`);
const toAdd: ChatMessage[] = incoming.map(m => {
console.log(`[Chat] Server-Sync: ${incoming.length} Nachrichten vom Server`);
const fromServer: ChatMessage[] = incoming.map(m => {
const role = m.role === 'user' ? 'user' : 'aria';
// ARIA-File-Marker aus dem Backup als attachments rekonstruieren
const files = Array.isArray(m.files) ? m.files : [];
const attachments = files.map((f: any) => ({
type: (typeof f.mimeType === 'string' && f.mimeType.startsWith('image/')) ? 'image' : 'file',
@@ -434,12 +444,25 @@ const ChatScreen: React.FC = () => {
});
const maxTs = incoming.reduce((mx: number, m: any) => Math.max(mx, m.ts || 0), 0);
setMessages(prev => {
// Dedup auf ts-basis: nicht erneut adden wenn schon was bei +/- 1s vorhanden
const existingTs = new Set(prev.map(m => m.timestamp));
const newOnes = toAdd.filter(m => !existingTs.has(m.timestamp));
return capMessages([...prev, ...newOnes]);
// Lokal-only Bubbles erkennen + behalten:
// - Skill-Created-Notifications (skillCreated gesetzt)
// - Laufende Sprachnachrichten ohne STT-Result (audioRequestId
// gesetzt UND text leer/Placeholder)
const localOnly = prev.filter(m =>
m.skillCreated ||
m.triggerCreated ||
(m.audioRequestId && (!m.text || m.text === '🎙 Aufnahme...' || m.text === 'Aufnahme...'))
);
// Server-Stand + lokal-only (chronologisch sortiert)
const merged = [...fromServer, ...localOnly].sort((a, b) => a.timestamp - b.timestamp);
return capMessages(merged);
});
if (maxTs > 0) AsyncStorage.setItem('aria_chat_last_sync', String(maxTs)).catch(() => {});
if (maxTs > 0) {
AsyncStorage.setItem('aria_chat_last_sync', String(maxTs)).catch(() => {});
} else {
// Server leer → unsere lastSync auch zuruecksetzen
AsyncStorage.removeItem('aria_chat_last_sync').catch(() => {});
}
return;
}
@@ -462,6 +485,26 @@ const ChatScreen: React.FC = () => {
return;
}
// trigger_created: ARIA hat einen Trigger angelegt → eigene Bubble
if (message.type === 'trigger_created') {
const p = (message.payload || {}) as any;
const triggerMsg: ChatMessage = {
id: nextId(),
sender: 'aria',
text: '',
timestamp: Date.now(),
triggerCreated: {
name: String(p.name || '(unbenannt)'),
type: String(p.type || 'timer'),
message: String(p.message || ''),
fires_at: p.fires_at ? String(p.fires_at) : undefined,
condition: p.condition ? String(p.condition) : undefined,
},
};
setMessages(prev => capMessages([...prev, triggerMsg]));
return;
}
// file_deleted: Datei wurde geloescht (vom Diagnostic User) → Bubble updaten
if (message.type === 'file_deleted') {
const p = (message.payload?.path as string) || '';
@@ -701,14 +744,13 @@ const ChatScreen: React.FC = () => {
const unsubState = rvs.onStateChange((state) => {
setConnectionState(state);
// Bei (re)connect: verpasste Chat-Eintraege seit der letzten gesehenen
// Nachricht abholen. lastChatSync wird beim Eingang von Nachrichten
// hochgezaehlt; default 0 = alle (gecappt auf Server-Limit).
// Bei (re)connect: KOMPLETTEN Server-Stand holen. Server ist die
// Source-of-Truth — wenn er leer ist (z.B. nach "Konversation
// zuruecksetzen"), soll die App das spiegeln, auch wenn sie offline
// war als das passiert ist. since=0 + limit=200 → die letzten 200
// Nachrichten vom Server, oder leeres Array wenn Server leer.
if (state === 'connected') {
AsyncStorage.getItem('aria_chat_last_sync').then(stored => {
const since = stored ? parseInt(stored, 10) || 0 : 0;
rvs.send('chat_history_request' as any, { since, limit: 100 });
}).catch(() => {});
rvs.send('chat_history_request' as any, { since: 0, limit: 200 });
}
});
@@ -907,17 +949,25 @@ const ChatScreen: React.FC = () => {
setSearchIndex(0);
}, [searchQuery]);
// Bei Index-Wechsel zu der entsprechenden Bubble scrollen
// Bei Index-Wechsel zu der entsprechenden Bubble scrollen.
// FlatList ist `inverted` → viewPosition 0.5 (mitte) ist beim inverted-Render
// tatsaechlich die Mitte des sichtbaren Bereichs. Wir verzoegern minimal
// damit Layout sicher fertig ist.
useEffect(() => {
if (!searchMatchIds.length) return;
const id = searchMatchIds[searchIndex];
if (!id) return;
// invertedMessages → index in der angezeigten Liste finden
const idx = invertedMessages.findIndex(m => m.id === id);
if (idx < 0 || !flatListRef.current) return;
try {
flatListRef.current.scrollToIndex({ index: idx, animated: true, viewPosition: 0.4 });
} catch {}
const tryScroll = () => {
try {
flatListRef.current?.scrollToIndex({ index: idx, animated: true, viewPosition: 0.5 });
} catch {
// wird von onScrollToIndexFailed nochmal versucht
}
};
// requestAnimationFrame statt setTimeout 0 — wartet auf naechsten Layout-Frame
requestAnimationFrame(tryScroll);
}, [searchIndex, searchMatchIds, invertedMessages]);
const activeSearchId = searchMatchIds[searchIndex] || '';
@@ -1186,6 +1236,28 @@ const ChatScreen: React.FC = () => {
? { borderWidth: 2, borderColor: '#FFD60A' }
: null;
// Spezial-Bubble: ARIA hat einen Trigger angelegt
if (item.triggerCreated) {
const t = item.triggerCreated;
const detailLine = t.type === 'timer'
? `feuert: ${t.fires_at || '?'}`
: `wenn: ${t.condition || '?'}`;
return (
<View style={[styles.messageBubble, styles.ariaBubble, {borderLeftWidth: 3, borderLeftColor: '#FFD60A'}, searchHighlightStyle]}>
<Text style={{color: '#FFD60A', fontWeight: 'bold', fontSize: 14}}>
{'⏰ ARIA hat einen Trigger angelegt'}
</Text>
<Text style={{color: '#E0E0F0', marginTop: 4, fontSize: 14}}>
<Text style={{fontWeight: 'bold'}}>{t.name}</Text>
<Text style={{color: '#8888AA', fontSize: 12}}>{` (${t.type})`}</Text>
</Text>
<Text style={{color: '#8888AA', fontSize: 12, marginTop: 2, fontFamily: 'monospace'}}>{detailLine}</Text>
<Text style={{color: '#888', fontSize: 12, marginTop: 2}}>{`"${t.message}"`}</Text>
<Text style={{color: '#555570', fontSize: 10, marginTop: 6}}>ARIA-Trigger · {time}</Text>
</View>
);
}
// Spezial-Bubble: ARIA hat einen Skill erstellt
if (item.skillCreated) {
const s = item.skillCreated;
@@ -1427,10 +1499,14 @@ const ChatScreen: React.FC = () => {
inverted
data={invertedMessages}
onScrollToIndexFailed={(info) => {
// Bei zu schnellem Aufruf vor Layout: einmal nachfassen
// FlatList kennt das Item-Layout noch nicht. Zuerst grob in die
// Naehe scrollen (Average-Item-Hoehe-Schaetzung), dann nach 250ms
// praezise nochmal versuchen.
const offset = info.averageItemLength * info.index;
try { flatListRef.current?.scrollToOffset({ offset, animated: false }); } catch {}
setTimeout(() => {
try { flatListRef.current?.scrollToIndex({ index: info.index, animated: true, viewPosition: 0.4 }); } catch {}
}, 200);
try { flatListRef.current?.scrollToIndex({ index: info.index, animated: true, viewPosition: 0.5 }); } catch {}
}, 250);
}}
keyExtractor={item => item.id}
renderItem={renderMessage}
+141 -1
View File
@@ -25,6 +25,8 @@ from memory import Embedder, VectorStore, MemoryPoint
from prompts import build_system_prompt
from proxy_client import ProxyClient, Message as ProxyMessage
import skills as skills_mod
import triggers as triggers_mod
import watcher as watcher_mod
logger = logging.getLogger(__name__)
@@ -90,6 +92,90 @@ META_TOOLS = [
"parameters": {"type": "object", "properties": {}},
},
},
{
"type": "function",
"function": {
"name": "trigger_timer",
"description": (
"Lege einen Timer-Trigger an — feuert EINMALIG zum angegebenen Zeitpunkt "
"und ruft dich selbst auf (Push-Nachricht an Stefan). "
"Use-Case: 'erinnere mich in 10min', 'sag mir um 14:30 Bescheid'."
),
"parameters": {
"type": "object",
"properties": {
"name": {"type": "string", "description": "kurzer kebab-case-Name, a-z 0-9 - _"},
"fires_at": {
"type": "string",
"description": (
"Absoluter ISO-Timestamp UTC, z.B. '2026-05-12T14:30:00Z'. "
"Berechne aus relativer Angabe ('in 10min') selbst — die "
"aktuelle Zeit findest du im System-Prompt nicht, also nutze "
"Bash: `date -u -d '+10 minutes' --iso-8601=seconds`."
),
},
"message": {"type": "string", "description": "Was soll bei der Erinnerung gesagt werden"},
},
"required": ["name", "fires_at", "message"],
},
},
},
{
"type": "function",
"function": {
"name": "trigger_watcher",
"description": (
"Lege einen Watcher-Trigger an — pollt alle paar Minuten eine Condition, "
"feuert wenn sie wahr wird (mit Throttle damit's nicht spammt). "
"Use-Case: 'sag bescheid wenn Disk unter 5GB', 'pingt mich wenn um 8 Uhr'. "
"Welche Variablen verfuegbar sind und ihre Bedeutung steht im System-Prompt."
),
"parameters": {
"type": "object",
"properties": {
"name": {"type": "string", "description": "kurzer Name"},
"condition": {
"type": "string",
"description": (
"Boolescher Ausdruck mit den erlaubten Variablen, z.B. "
"'disk_free_gb < 5', 'hour_of_day == 8 and day_of_week == \"mon\"'. "
"Operatoren: < > <= >= == != and or not"
),
},
"message": {"type": "string", "description": "Was soll bei Erfuellung gesagt werden"},
"check_interval_sec": {
"type": "integer",
"description": "Wie oft Condition pruefen (Default 300 = alle 5min, min 30)",
},
"throttle_sec": {
"type": "integer",
"description": "Mindestabstand zwischen 2 Feuerungen (Default 3600 = max 1x/h)",
},
},
"required": ["name", "condition", "message"],
},
},
},
{
"type": "function",
"function": {
"name": "trigger_cancel",
"description": "Loescht einen Trigger (Timer abbrechen oder Watcher entfernen).",
"parameters": {
"type": "object",
"properties": {"name": {"type": "string"}},
"required": ["name"],
},
},
},
{
"type": "function",
"function": {
"name": "trigger_list",
"description": "Zeigt alle Trigger (active + inaktiv). Selten noetig — Stefan sieht sie im Diagnostic.",
"parameters": {"type": "object", "properties": {}},
},
},
]
@@ -175,8 +261,14 @@ class Agent:
active_skills = [s for s in all_skills if s.get("active", True)]
tools = list(META_TOOLS) + [_skill_to_tool(s) for s in active_skills]
# Trigger-Liste + Variablen-Info fuer den System-Prompt
all_triggers = triggers_mod.list_triggers(active_only=False)
condition_vars = watcher_mod.describe_variables()
# 5. System-Prompt + Window-Messages
system_prompt = build_system_prompt(hot, cold, skills=all_skills)
system_prompt = build_system_prompt(hot, cold, skills=all_skills,
triggers=all_triggers,
condition_vars=condition_vars)
messages = [ProxyMessage(role="system", content=system_prompt)]
for t in self.conversation.window():
messages.append(ProxyMessage(role=t.role, content=t.content))
@@ -273,6 +365,54 @@ class Agent:
if err:
out += f"\nstderr:\n{err}"
return out
if name == "trigger_timer":
t = triggers_mod.create_timer(
name=arguments["name"],
fires_at_iso=arguments["fires_at"],
message=arguments["message"],
author="aria",
)
self._pending_events.append({
"type": "trigger_created",
"trigger": {"name": t["name"], "type": "timer",
"fires_at": t["fires_at"], "message": t["message"]},
})
return f"OK — Timer '{t['name']}' angelegt, feuert um {t['fires_at']}."
if name == "trigger_watcher":
t = triggers_mod.create_watcher(
name=arguments["name"],
condition=arguments["condition"],
message=arguments["message"],
check_interval_sec=int(arguments.get("check_interval_sec", 300)),
throttle_sec=int(arguments.get("throttle_sec", 3600)),
author="aria",
)
self._pending_events.append({
"type": "trigger_created",
"trigger": {"name": t["name"], "type": "watcher",
"condition": t["condition"], "message": t["message"]},
})
return f"OK — Watcher '{t['name']}' angelegt: feuert wenn '{t['condition']}'."
if name == "trigger_cancel":
try:
triggers_mod.delete(arguments["name"])
return f"OK — Trigger '{arguments['name']}' geloescht."
except ValueError as e:
return f"FEHLER: {e}"
if name == "trigger_list":
items = triggers_mod.list_triggers(active_only=False)
if not items:
return "(keine Trigger vorhanden)"
lines = []
for t in items:
state = "aktiv" if t.get("active", True) else "DEAKTIVIERT"
if t["type"] == "timer":
lines.append(f"- {t['name']} (timer, {state}): feuert {t.get('fires_at')}\"{t.get('message','')[:50]}\"")
elif t["type"] == "watcher":
lines.append(f"- {t['name']} (watcher, {state}): cond=\"{t.get('condition')}\", throttle={t.get('throttle_sec')}s")
else:
lines.append(f"- {t['name']} ({t['type']}, {state})")
return "\n".join(lines)
return f"Unbekanntes Tool: {name}"
except Exception as exc:
logger.exception("Tool '%s' fehlgeschlagen", name)
+169
View File
@@ -0,0 +1,169 @@
"""
Background-Loop fuer Triggers.
Laeuft alle TICK_SEC Sekunden in einem asyncio Task, geht ueber alle
active Triggers und entscheidet ob sie feuern muessen.
Feuern bedeutet:
1. Trigger-Manifest update (fire_count++, last_fired_at, ggf. deaktivieren)
2. Log-Eintrag schreiben
3. agent.chat() mit einem system-Praefix aufrufen (NICHT als 'user'!)
→ ARIA bekommt das wie eine Push-Nachricht und kann antworten
"""
from __future__ import annotations
import asyncio
import logging
from datetime import datetime, timezone
from typing import Optional
import triggers as triggers_mod
import watcher as watcher_mod
logger = logging.getLogger(__name__)
TICK_SEC = 30
def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
def _parse_iso(s: str) -> Optional[datetime]:
if not s:
return None
try:
return datetime.fromisoformat(s.replace("Z", "+00:00"))
except Exception:
return None
def _should_fire(trigger: dict, vars_: dict, now: datetime) -> bool:
if not trigger.get("active", True):
return False
t = trigger.get("type", "")
if t == "timer":
fires_at = _parse_iso(trigger.get("fires_at", ""))
if not fires_at:
return False
if fires_at.tzinfo is None:
fires_at = fires_at.replace(tzinfo=timezone.utc)
return now >= fires_at
if t == "watcher":
# Check-Interval respektieren (sonst pollen wir zu hektisch)
check_interval = int(trigger.get("check_interval_sec", 300))
last_checked = _parse_iso(trigger.get("last_checked_at", ""))
if last_checked:
if last_checked.tzinfo is None:
last_checked = last_checked.replace(tzinfo=timezone.utc)
if (now - last_checked).total_seconds() < check_interval:
return False
# Throttle: erst feuern wenn last_fired lange genug her ist
last_fired = _parse_iso(trigger.get("last_fired_at", ""))
throttle = int(trigger.get("throttle_sec", 3600))
if last_fired:
if last_fired.tzinfo is None:
last_fired = last_fired.replace(tzinfo=timezone.utc)
if (now - last_fired).total_seconds() < throttle:
return False
# Condition pruefen
cond = (trigger.get("condition") or "").strip()
if not cond:
return False
try:
return watcher_mod.evaluate(cond, vars_)
except Exception as e:
logger.warning("Trigger %s: Condition '%s' fehlerhaft: %s",
trigger.get("name"), cond, e)
return False
if t == "cron":
# TODO: später, wenn jemand Bock auf Cron-Parser hat
return False
return False
async def _fire(trigger: dict, agent_factory) -> None:
"""Ruft ARIA mit einer System-Praefix-Nachricht auf."""
name = trigger.get("name", "?")
message = trigger.get("message") or "(ohne Nachricht)"
ttype = trigger.get("type", "?")
# Manifest updaten
try:
triggers_mod.mark_fired(name)
except Exception as e:
logger.warning("mark_fired %s: %s", name, e)
# Log
triggers_mod.append_log(name, {"event": "fired", "type": ttype, "message": message})
# System-Nachricht an ARIA: nicht als User, sondern als Hinweis
prompt = (
f"[Trigger ausgelöst: '{name}', Typ: {ttype}] "
f"Geplante Nachricht: \"{message}\". "
f"Sage Stefan jetzt diese Information, in deinem Stil. "
f"Wenn der Trigger ein Watcher war (Bedingung wurde erfuellt), "
f"erwaehne kurz worum es geht. Antworte direkt, keine Rueckfrage."
)
try:
agent = agent_factory()
reply = agent.chat(prompt, source="trigger")
logger.info("[trigger] %s gefeuert → ARIA-Reply: %s", name, reply[:80])
triggers_mod.append_log(name, {"event": "reply", "text": reply[:500]})
except Exception as e:
logger.exception("Trigger %s feuern fehlgeschlagen: %s", name, e)
triggers_mod.append_log(name, {"event": "error", "error": str(e)[:300]})
async def _tick(agent_factory) -> None:
"""Ein Pruefdurchlauf. Geht ueber alle Triggers, feuert was zu feuern ist."""
try:
all_triggers = triggers_mod.list_triggers(active_only=True)
except Exception as e:
logger.warning("triggers.list: %s", e)
return
if not all_triggers:
return
now = datetime.now(timezone.utc)
# Variablen einmal pro Tick sammeln (nicht pro Trigger — Disk-Stat ist teuer)
try:
vars_ = watcher_mod.collect_variables()
except Exception as e:
logger.warning("collect_variables: %s", e)
vars_ = {}
# Watcher: last_checked_at jetzt updaten (auch wenn nicht gefeuert wird,
# damit der Check-Interval respektiert wird)
for t in all_triggers:
if t.get("type") == "watcher":
try:
t["last_checked_at"] = _now_iso()
triggers_mod.write(t["name"], t)
except Exception:
pass
for trigger in all_triggers:
try:
if _should_fire(trigger, vars_, now):
# Feuern als eigener Task — wenn ARIA langsam antwortet,
# darf der naechste Tick nicht blockieren
asyncio.create_task(_fire(trigger, agent_factory))
except Exception as e:
logger.warning("Trigger-Check %s: %s", trigger.get("name"), e)
async def run_loop(agent_factory) -> None:
"""Endlosschleife — wird vom main lifespan gestartet + gestoppt."""
logger.info("Trigger-Loop gestartet (TICK_SEC=%d)", TICK_SEC)
while True:
try:
await _tick(agent_factory)
except Exception as e:
logger.exception("Tick-Fehler: %s", e)
await asyncio.sleep(TICK_SEC)
+120 -1
View File
@@ -20,6 +20,9 @@ import logging
import os
from typing import List, Optional
import asyncio
from contextlib import asynccontextmanager
from fastapi import FastAPI, HTTPException, BackgroundTasks, Request
from fastapi.responses import Response
from pydantic import BaseModel, Field
@@ -30,6 +33,9 @@ from proxy_client import ProxyClient
from agent import Agent
import skills as skills_mod
import metrics as metrics_mod
import triggers as triggers_mod
import watcher as watcher_mod
import background as background_mod
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s")
logger = logging.getLogger("aria-brain")
@@ -37,7 +43,23 @@ logger = logging.getLogger("aria-brain")
QDRANT_HOST = os.environ.get("QDRANT_HOST", "aria-qdrant")
QDRANT_PORT = int(os.environ.get("QDRANT_PORT", "6333"))
app = FastAPI(title="ARIA Brain", version="0.1.0")
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Beim Brain-Start: Trigger-Background-Loop anwerfen. Beim Shutdown: stoppen."""
task = asyncio.create_task(background_mod.run_loop(agent))
logger.info("Lifespan: Trigger-Loop gestartet")
try:
yield
finally:
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
logger.info("Lifespan: Trigger-Loop gestoppt")
app = FastAPI(title="ARIA Brain", version="0.1.0", lifespan=lifespan)
_embedder: Optional[Embedder] = None
_store: Optional[VectorStore] = None
@@ -414,6 +436,103 @@ def metrics_calls():
return metrics_mod.stats()
# ─── Triggers (passive Aufweck-Quellen) ─────────────────────────────
class TriggerTimerBody(BaseModel):
name: str
fires_at: str # ISO timestamp
message: str
author: str = "stefan"
class TriggerWatcherBody(BaseModel):
name: str
condition: str
message: str
check_interval_sec: int = 300
throttle_sec: int = 3600
author: str = "stefan"
class TriggerPatch(BaseModel):
active: bool | None = None
message: str | None = None
condition: str | None = None
throttle_sec: int | None = None
check_interval_sec: int | None = None
fires_at: str | None = None
@app.get("/triggers/list")
def triggers_list(active_only: bool = False):
return {"triggers": triggers_mod.list_triggers(active_only=active_only)}
@app.get("/triggers/conditions")
def triggers_conditions():
"""Verfuegbare Variablen fuer Watcher-Conditions (mit aktuellen Werten)."""
return {
"variables": watcher_mod.describe_variables(),
"current": watcher_mod.collect_variables(),
}
@app.get("/triggers/{name}")
def triggers_get(name: str):
t = triggers_mod.read(name)
if t is None:
raise HTTPException(404, f"Trigger '{name}' nicht gefunden")
return t
@app.get("/triggers/{name}/logs")
def triggers_get_logs(name: str, limit: int = 50):
return {"logs": triggers_mod.list_logs(name, limit=limit)}
@app.post("/triggers/timer")
def triggers_create_timer(body: TriggerTimerBody):
try:
return triggers_mod.create_timer(
name=body.name, fires_at_iso=body.fires_at,
message=body.message, author=body.author,
)
except ValueError as exc:
raise HTTPException(400, str(exc))
@app.post("/triggers/watcher")
def triggers_create_watcher(body: TriggerWatcherBody):
try:
return triggers_mod.create_watcher(
name=body.name, condition=body.condition,
message=body.message,
check_interval_sec=body.check_interval_sec,
throttle_sec=body.throttle_sec,
author=body.author,
)
except ValueError as exc:
raise HTTPException(400, str(exc))
@app.patch("/triggers/{name}")
def triggers_patch(name: str, body: TriggerPatch):
patch = {k: v for k, v in body.model_dump().items() if v is not None}
try:
return triggers_mod.update(name, patch)
except ValueError as exc:
raise HTTPException(404, str(exc))
@app.delete("/triggers/{name}")
def triggers_delete(name: str):
try:
triggers_mod.delete(name)
except ValueError as exc:
raise HTTPException(404, str(exc))
return {"deleted": name}
# ─── Skills ─────────────────────────────────────────────────────────
class SkillCreate(BaseModel):
+38 -1
View File
@@ -115,16 +115,53 @@ def build_skills_section(skills: List[dict]) -> str:
return "\n".join(lines)
def build_triggers_section(triggers: List[dict], condition_vars: List[dict]) -> str:
"""Triggers (passive Aufweck-Quellen) + verfuegbare Condition-Variablen."""
lines = ["## Trigger (passive Aufweck-Quellen)"]
lines.append("")
lines.append("Trigger sind ANDERS als Skills: das System ruft DICH wenn ein Event passiert. "
"Du legst sie an wenn Stefan sagt 'erinner mich an X' oder 'sag bescheid wenn Y'.")
lines.append("")
if triggers:
lines.append("### Aktuelle Trigger")
for t in triggers:
active = t.get("active", True)
mark = "" if active else " [INAKTIV]"
if t["type"] == "timer":
lines.append(f"- **{t['name']}**{mark} (timer) feuert {t.get('fires_at')}: \"{t.get('message','')[:80]}\"")
elif t["type"] == "watcher":
lines.append(f"- **{t['name']}**{mark} (watcher) cond=`{t.get('condition')}`: \"{t.get('message','')[:80]}\"")
lines.append("")
lines.append("### Verfuegbare Condition-Variablen (fuer Watcher)")
for v in condition_vars:
lines.append(f"- `{v['name']}` ({v['type']}) — {v['desc']}")
lines.append("")
lines.append("Operatoren in Conditions: `<` `>` `<=` `>=` `==` `!=` `and` `or` `not`. "
"Beispiel: `disk_free_gb < 5 and hour_of_day >= 8`. "
"String-Werte in Quotes: `day_of_week == \"mon\"`.")
lines.append("")
lines.append("### Wann welcher Typ?")
lines.append("- **Timer** fuer einmalige Erinnerungen mit konkreter Zeit ('in 10min', 'um 14:30').")
lines.append("- **Watcher** fuer 'wenn X passiert' (Disk voll, bestimmte Tageszeit).")
lines.append("- ARIA legt Trigger NUR auf Stefan-Wunsch an, nicht eigenmaechtig.")
return "\n".join(lines)
def build_system_prompt(
pinned: List[MemoryPoint],
cold: List[MemoryPoint] | None = None,
skills: List[dict] | None = None,
triggers: List[dict] | None = None,
condition_vars: List[dict] | None = None,
) -> str:
"""Kompletter System-Prompt: Hot + Cold + Skills."""
"""Kompletter System-Prompt: Hot + Cold + Skills + Triggers."""
parts = [build_hot_memory_section(pinned)]
if skills:
parts.append("")
parts.append(build_skills_section(skills))
if condition_vars:
parts.append("")
parts.append(build_triggers_section(triggers or [], condition_vars))
if cold:
parts.append("")
parts.append(build_cold_memory_section(cold))
+229
View File
@@ -0,0 +1,229 @@
"""
Triggers passive Aufweck-Quellen fuer ARIA.
Skills sind aktiv (ARIA ruft sie). Triggers sind passiv das System ruft
ARIA wenn ein Event passiert. Drei Typen:
timer Einmalig zu einem festen Zeitpunkt
watcher Recurring: Condition pruefen, bei True feuern (mit Throttle)
cron Cron-Expression (vorerst nicht implementiert, Platzhalter)
Layout:
/data/triggers/<name>.json Manifest pro Trigger
/data/triggers/logs/<name>.jsonl Append-only Log pro Feuerung
Polling-Kosten: Brain-internes Background-Polling (kein LLM-Call).
ARIA wird nur aufgeweckt wenn ein Trigger tatsaechlich feuert.
"""
from __future__ import annotations
import json
import logging
import os
import re
import shutil
import time
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
logger = logging.getLogger(__name__)
TRIGGERS_DIR = Path(os.environ.get("TRIGGERS_DIR", "/data/triggers"))
LOGS_DIR = TRIGGERS_DIR / "logs"
NAME_RE = re.compile(r"^[a-zA-Z0-9_-]{2,60}$")
VALID_TYPES = {"timer", "watcher", "cron"}
def _now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
def _safe_name(name: str) -> str:
if not isinstance(name, str) or not NAME_RE.match(name):
raise ValueError(f"Ungueltiger Trigger-Name: {name!r}")
return name
def _path(name: str) -> Path:
return TRIGGERS_DIR / f"{_safe_name(name)}.json"
def _ensure_dirs():
TRIGGERS_DIR.mkdir(parents=True, exist_ok=True)
LOGS_DIR.mkdir(parents=True, exist_ok=True)
# ─── CRUD ───────────────────────────────────────────────────────────
def list_triggers(active_only: bool = False) -> list[dict]:
if not TRIGGERS_DIR.exists():
return []
out: list[dict] = []
for f in sorted(TRIGGERS_DIR.glob("*.json")):
try:
data = json.loads(f.read_text(encoding="utf-8"))
if active_only and not data.get("active", True):
continue
out.append(data)
except Exception as e:
logger.warning("Trigger lesen %s: %s", f, e)
return out
def read(name: str) -> Optional[dict]:
p = _path(name)
if not p.exists():
return None
try:
return json.loads(p.read_text(encoding="utf-8"))
except Exception as e:
logger.warning("Trigger %s lesen: %s", name, e)
return None
def write(name: str, data: dict) -> None:
_ensure_dirs()
data["updated_at"] = _now_iso()
p = _path(name)
tmp = p.with_suffix(".tmp")
tmp.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
tmp.replace(p)
def delete(name: str) -> None:
p = _path(name)
if not p.exists():
raise ValueError(f"Trigger '{name}' nicht gefunden")
p.unlink()
# Logs auch wegraeumen
log_file = LOGS_DIR / f"{_safe_name(name)}.jsonl"
if log_file.exists():
log_file.unlink()
def update(name: str, patch: dict) -> dict:
data = read(name)
if data is None:
raise ValueError(f"Trigger '{name}' nicht gefunden")
allowed = {"active", "message", "condition", "throttle_sec",
"check_interval_sec", "fires_at"}
for k, v in patch.items():
if k in allowed:
data[k] = v
write(name, data)
return data
# ─── Create-Helpers (typ-spezifisch) ────────────────────────────────
def create_timer(
name: str,
fires_at_iso: str,
message: str,
author: str = "aria",
) -> dict:
_safe_name(name)
if _path(name).exists():
raise ValueError(f"Trigger '{name}' existiert schon")
# ISO validieren
try:
datetime.fromisoformat(fires_at_iso.replace("Z", "+00:00"))
except Exception:
raise ValueError(f"fires_at_iso ungueltig: {fires_at_iso}")
data = {
"name": name,
"type": "timer",
"active": True,
"author": author,
"created_at": _now_iso(),
"fires_at": fires_at_iso,
"message": message,
"fire_count": 0,
"last_fired_at": None,
}
write(name, data)
logger.info("Trigger angelegt: %s (timer, fires_at=%s)", name, fires_at_iso)
return data
def create_watcher(
name: str,
condition: str,
message: str,
check_interval_sec: int = 300,
throttle_sec: int = 3600,
author: str = "aria",
) -> dict:
_safe_name(name)
if _path(name).exists():
raise ValueError(f"Trigger '{name}' existiert schon")
# Condition parsen-pruefen (wirft bei Syntax-Fehler)
from watcher import parse_condition
parse_condition(condition) # nur Validate
if check_interval_sec < 30:
check_interval_sec = 30 # nicht oefter als alle 30s pruefen
if throttle_sec < 0:
throttle_sec = 0
data = {
"name": name,
"type": "watcher",
"active": True,
"author": author,
"created_at": _now_iso(),
"condition": condition,
"check_interval_sec": int(check_interval_sec),
"throttle_sec": int(throttle_sec),
"message": message,
"fire_count": 0,
"last_fired_at": None,
"last_checked_at": None,
}
write(name, data)
logger.info("Trigger angelegt: %s (watcher, cond='%s')", name, condition)
return data
# ─── Feuern + Log ───────────────────────────────────────────────────
def mark_fired(name: str) -> dict:
data = read(name)
if data is None:
raise ValueError(f"Trigger '{name}' nicht gefunden")
data["fire_count"] = int(data.get("fire_count", 0)) + 1
data["last_fired_at"] = _now_iso()
# Timer: nach Feuern auto-deaktivieren (one-shot)
if data.get("type") == "timer":
data["active"] = False
write(name, data)
return data
def append_log(name: str, entry: dict) -> None:
_ensure_dirs()
log_file = LOGS_DIR / f"{_safe_name(name)}.jsonl"
record = {"ts": _now_iso()}
record.update(entry)
try:
with log_file.open("a", encoding="utf-8") as f:
f.write(json.dumps(record, ensure_ascii=False) + "\n")
except Exception as e:
logger.warning("Trigger-Log append %s: %s", name, e)
def list_logs(name: str, limit: int = 50) -> list[dict]:
log_file = LOGS_DIR / f"{_safe_name(name)}.jsonl"
if not log_file.exists():
return []
try:
lines = log_file.read_text(encoding="utf-8").splitlines()
out: list[dict] = []
for line in lines[-limit:]:
try:
out.append(json.loads(line))
except Exception:
pass
return out
except Exception:
return []
+150
View File
@@ -0,0 +1,150 @@
"""
Built-in Condition-Variablen + sicherer Mini-Parser fuer Watcher-Triggers.
Erlaubte Variablen kommen aus diesem Modul. Condition-Ausdruck ist ein
sicheres Subset von Python (kein eval, kein exec): nur Vergleiche und
Boolean-Operatoren, nur die hier deklarierten Variablen, nur Zahlen +
String-Literale als rechte Seite.
Beispiele:
disk_free_gb < 5
hour_of_day == 8 and day_of_week == "mon"
rvs_connected == False
(disk_free_pct < 10 and uptime_sec > 3600)
"""
from __future__ import annotations
import ast
import logging
import os
import shutil
import time
from datetime import datetime
from pathlib import Path
from typing import Any
logger = logging.getLogger(__name__)
# ─── Variablen-Quellen ──────────────────────────────────────────────
def _disk_stats() -> tuple[float, float]:
"""Returns (free_gb, free_pct). Schaut /shared (geteiltes Volume) — sonst /."""
target = "/shared" if os.path.exists("/shared") else "/"
try:
st = shutil.disk_usage(target)
free_gb = st.free / (1024 ** 3)
free_pct = 100.0 * st.free / st.total if st.total else 0.0
return free_gb, free_pct
except Exception as e:
logger.warning("disk_usage: %s", e)
return 0.0, 0.0
def _uptime_sec() -> int:
try:
with open("/proc/uptime", "r") as f:
return int(float(f.read().split()[0]))
except Exception:
return 0
def _rvs_connected() -> bool:
"""Liest /shared/config/runtime.json oder ein Bridge-State-File.
Aktuell: wir koennen das nicht zuverlaessig aus dem Brain-Container
bestimmen gibt False als sicheren Default zurueck.
Spaeter: Bridge schreibt einen Heartbeat-File den wir hier lesen."""
return False
_DAYS = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"]
def collect_variables() -> dict[str, Any]:
"""Liefert aktuellen Snapshot aller Built-in-Variablen."""
free_gb, free_pct = _disk_stats()
now = datetime.now()
# Memory-Count aus der Vector-DB (importiert lazy um zirkulaere Imports
# zu vermeiden — beim Modul-Load gibt's noch keinen Store)
memory_count = 0
try:
from main import store # type: ignore
s = store()
memory_count = s.count()
except Exception:
pass
return {
"disk_free_gb": round(free_gb, 2),
"disk_free_pct": round(free_pct, 1),
"uptime_sec": _uptime_sec(),
"hour_of_day": now.hour,
"day_of_week": _DAYS[now.weekday()],
"rvs_connected": _rvs_connected(),
"memory_count": memory_count,
}
def describe_variables() -> list[dict]:
"""Liste der verfuegbaren Variablen + Beschreibung — fuer System-Prompt + UI."""
return [
{"name": "disk_free_gb", "type": "number", "desc": "freier Plattenplatz in GB (auf /shared)"},
{"name": "disk_free_pct", "type": "number", "desc": "freier Plattenplatz in Prozent"},
{"name": "uptime_sec", "type": "number", "desc": "Sekunden seit Brain-Start"},
{"name": "hour_of_day", "type": "number", "desc": "0..23, lokale Zeit"},
{"name": "day_of_week", "type": "string", "desc": "mon|tue|wed|thu|fri|sat|sun"},
{"name": "rvs_connected", "type": "bool", "desc": "True wenn RVS-Verbindung steht"},
{"name": "memory_count", "type": "number", "desc": "Anzahl Memories in der Vector-DB"},
]
# ─── Sicherer Condition-Parser ──────────────────────────────────────
_ALLOWED_NODES = (
ast.Expression, ast.BoolOp, ast.UnaryOp, ast.Compare,
ast.Name, ast.Constant, ast.Load,
ast.And, ast.Or, ast.Not,
ast.Eq, ast.NotEq, ast.Lt, ast.LtE, ast.Gt, ast.GtE,
)
def parse_condition(expr: str) -> ast.Expression:
"""Parst einen Condition-Ausdruck und validiert ihn gegen das Safe-Subset.
Wirft ValueError bei verbotenen Konstrukten."""
expr = (expr or "").strip()
if not expr:
raise ValueError("Leere Condition")
if len(expr) > 500:
raise ValueError("Condition zu lang (>500 Zeichen)")
try:
tree = ast.parse(expr, mode="eval")
except SyntaxError as e:
raise ValueError(f"Condition Syntax-Fehler: {e}")
# Whitelist-Walk
allowed_names = {v["name"] for v in describe_variables()}
for node in ast.walk(tree):
if not isinstance(node, _ALLOWED_NODES):
raise ValueError(f"Verbotener Ausdruck: {type(node).__name__}")
if isinstance(node, ast.Name):
if node.id not in allowed_names and node.id not in ("True", "False"):
raise ValueError(f"Unbekannte Variable: {node.id}")
if isinstance(node, ast.Constant):
if not isinstance(node.value, (int, float, str, bool)) and node.value is not None:
raise ValueError(f"Verbotener Konstant-Typ: {type(node.value).__name__}")
return tree
def evaluate(expr: str, variables: dict[str, Any] | None = None) -> bool:
"""Evaluiert die Condition gegen die aktuellen Variablen.
Returns bool. Bei Fehler in Variablen False (defensiv)."""
tree = parse_condition(expr)
vars_ = variables if variables is not None else collect_variables()
code = compile(tree, "<condition>", "eval")
# Globals leer, locals nur die erlaubten Variablen → kein Builtin-Zugriff
try:
result = eval(code, {"__builtins__": {}}, vars_)
except Exception as e:
logger.warning("Condition '%s' eval-Fehler: %s", expr, e)
return False
return bool(result)
+31 -28
View File
@@ -1086,6 +1086,12 @@ class ARIABridge:
except Exception as e:
logger.error("[core] XTTS-Request fehlgeschlagen: %s — kein Audio", e)
# ARIA ist fertig — App's "ARIA denkt..." Indicator zurueck auf idle.
# _last_chat_final_at bewusst NICHT setzen: die 3s-Cooldown war fuer
# trailing OpenClaw-Activity-Events; bei Voice-Chat wuerde sie die
# naechste thinking-Welle unterdruecken.
await self._emit_activity("idle", "")
# ── Mode Persistence (global, nicht pro Geraet) ──────
_MODE_FILE = "/shared/config/mode.json"
@@ -1250,12 +1256,9 @@ class ARIABridge:
# / Diagnostic-Reload als History-Quelle gelesen.
self._append_chat_backup({"role": "user", "text": text, "source": source})
# agent_activity broadcasten (App + Diagnostic "ARIA denkt..." Indicator)
await self._send_to_rvs({
"type": "agent_activity",
"payload": {"activity": "thinking"},
"timestamp": int(asyncio.get_event_loop().time() * 1000),
})
# agent_activity → thinking. _emit_activity statt direktem _send_to_rvs
# damit der State-Cache fuer die spaetere idle-Dedup richtig steht.
await self._emit_activity("thinking", "")
def _do_call():
try:
@@ -1272,11 +1275,7 @@ class ARIABridge:
status, body = await asyncio.get_event_loop().run_in_executor(None, _do_call)
if status != 200:
logger.error("[brain] /chat fehlgeschlagen: status=%s body=%s", status, body[:200])
await self._send_to_rvs({
"type": "agent_activity",
"payload": {"activity": "idle"},
"timestamp": int(asyncio.get_event_loop().time() * 1000),
})
await self._emit_activity("idle", "")
await self._send_to_rvs({
"type": "chat",
"payload": {
@@ -1291,21 +1290,13 @@ class ARIABridge:
data = json.loads(body)
except Exception:
logger.error("[brain] /chat lieferte ungueltiges JSON: %s", body[:200])
await self._send_to_rvs({
"type": "agent_activity",
"payload": {"activity": "idle"},
"timestamp": int(asyncio.get_event_loop().time() * 1000),
})
await self._emit_activity("idle", "")
return
reply = (data.get("reply") or "").strip()
if not reply:
logger.warning("[brain] /chat: leerer Reply")
await self._send_to_rvs({
"type": "agent_activity",
"payload": {"activity": "idle"},
"timestamp": int(asyncio.get_event_loop().time() * 1000),
})
await self._emit_activity("idle", "")
return
# Side-Channel-Events VOR der Chat-Bubble broadcasten (z.B. skill_created)
@@ -1320,6 +1311,14 @@ class ARIABridge:
})
logger.info("[brain] ARIA hat einen Skill erstellt: %s",
event.get("skill", {}).get("name"))
elif etype == "trigger_created":
await self._send_to_rvs({
"type": "trigger_created",
"payload": event.get("trigger", {}),
"timestamp": int(asyncio.get_event_loop().time() * 1000),
})
logger.info("[brain] ARIA hat einen Trigger angelegt: %s",
event.get("trigger", {}).get("name"))
# _process_core_response uebernimmt alles weitere:
# File-Marker extrahieren + broadcasten, NO_REPLY-Check, Chat-
@@ -1331,6 +1330,8 @@ class ARIABridge:
await self._process_core_response(reply, {})
except Exception:
logger.exception("[brain] _process_core_response Fehler")
await self._emit_activity("idle", "")
# Originaler Fallback-Send (toter Code, _emit_activity uebernimmt jetzt)
await self._send_to_rvs({
"type": "agent_activity",
"payload": {"activity": "idle"},
@@ -2050,13 +2051,11 @@ class ARIABridge:
if text.strip():
logger.info("[rvs] STT Ergebnis: '%s'", text[:80])
# Hints (Barge-In, GPS) als Praefix vorschalten — gemeinsamer Helper
# mit dem chat-Pfad damit das Verhalten konsistent ist.
core_text = self._build_core_text(text, interrupted, location)
# ERST an aria-core senden (wichtigster Schritt)
await self.send_to_core(core_text, source="app-voice" + (" [barge-in]" if interrupted else ""))
# STT-Text an RVS senden (fuer Anzeige in App + Diagnostic)
# sender="stt" damit Bridge es ignoriert (kein Loop)
# Reihenfolge wichtig: STT-Text ZUERST broadcasten damit die App
# die Voice-Bubble sofort mit dem erkannten Text aktualisieren
# kann — send_to_core blockt danach synchron auf Brain (kann
# dauern), wuerde sonst die Anzeige verzoegern.
try:
stt_payload = {
"text": text,
@@ -2080,6 +2079,10 @@ class ARIABridge:
logger.warning("[rvs] STT-Text NICHT broadcastet — _send_to_rvs lieferte False")
except Exception as e:
logger.warning("[rvs] STT-Text konnte nicht an RVS gesendet werden: %s", e)
# Dann an Brain — der blockt synchron bis ARIA fertig ist.
core_text = self._build_core_text(text, interrupted, location)
await self.send_to_core(core_text, source="app-voice" + (" [barge-in]" if interrupted else ""))
else:
logger.info("[rvs] Keine Sprache erkannt — ignoriert")
+297 -4
View File
@@ -221,6 +221,7 @@
<button class="main-nav-btn active" onclick="switchMainTab('main')">Main</button>
<button class="main-nav-btn" onclick="switchMainTab('brain')">Gehirn</button>
<button class="main-nav-btn" onclick="switchMainTab('skills')">Skills</button>
<button class="main-nav-btn" onclick="switchMainTab('triggers')">Trigger</button>
<button class="main-nav-btn" onclick="switchMainTab('files')">Dateien</button>
<button class="main-nav-btn" onclick="switchMainTab('settings')">Einstellungen</button>
</div>
@@ -899,6 +900,74 @@
</div>
</div><!-- /tab-skills -->
<!-- ══════ TAB: Trigger ══════ -->
<div id="tab-triggers" class="main-tab">
<div class="settings-section">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;">
<h2 style="margin:0;">Trigger <button class="info-btn" onclick="showInfo('triggers')" title="Was sind Trigger?"></button></h2>
<div style="display:flex;gap:6px;">
<button class="btn secondary" onclick="loadTriggers()" style="padding:4px 10px;font-size:11px;">Aktualisieren</button>
<button class="btn" onclick="openTriggerCreate()" style="padding:4px 10px;font-size:11px;">+ Neu</button>
</div>
</div>
<div class="card" style="margin-bottom:8px;">
<p style="color:#8888AA;font-size:12px;margin:0;">
Trigger sind passive Aufweck-Quellen. Skills sind aktiv (ARIA ruft sie),
Trigger sind passiv (System ruft ARIA wenn ein Event passiert). Polling
kostet keine Tokens — nur das Feuern verbraucht eine Anfrage.
</p>
</div>
<div class="card">
<div id="triggers-list" style="font-size:12px;color:#8888AA;">(Lade...)</div>
</div>
</div>
</div><!-- /tab-triggers -->
<!-- Trigger-Create Modal -->
<div class="modal-overlay" id="trigger-modal">
<div class="modal-box" style="max-width:600px;">
<div class="modal-header">
<h3>Neuer Trigger</h3>
<button class="modal-close" onclick="closeTriggerModal()">&times;</button>
</div>
<div class="modal-body" style="padding:16px;">
<label style="display:block;font-size:11px;color:#8888AA;margin-bottom:4px;">Typ</label>
<select id="trigger-type" onchange="onTriggerTypeChange()" style="width:100%;background:#0D0D1A;color:#E0E0F0;border:1px solid #1E1E2E;padding:6px;border-radius:4px;font-family:inherit;margin-bottom:10px;">
<option value="timer">Timer — einmalig zu festem Zeitpunkt</option>
<option value="watcher">Watcher — wenn Bedingung wahr</option>
</select>
<label style="display:block;font-size:11px;color:#8888AA;margin-bottom:4px;">Name</label>
<input type="text" id="trigger-name" placeholder="z.B. pasta, disk-warn" style="width:100%;background:#0D0D1A;color:#E0E0F0;border:1px solid #1E1E2E;padding:6px;border-radius:4px;font-family:inherit;margin-bottom:10px;">
<!-- Timer-spezifisch -->
<div id="trigger-timer-fields">
<label style="display:block;font-size:11px;color:#8888AA;margin-bottom:4px;">In wievielen Minuten?</label>
<input type="number" id="trigger-timer-minutes" min="1" max="10080" value="10" style="width:100%;background:#0D0D1A;color:#E0E0F0;border:1px solid #1E1E2E;padding:6px;border-radius:4px;font-family:inherit;margin-bottom:10px;">
</div>
<!-- Watcher-spezifisch -->
<div id="trigger-watcher-fields" style="display:none;">
<label style="display:block;font-size:11px;color:#8888AA;margin-bottom:4px;">Condition (siehe Variablen unten)</label>
<input type="text" id="trigger-condition" placeholder="z.B. disk_free_gb < 5" style="width:100%;background:#0D0D1A;color:#E0E0F0;border:1px solid #1E1E2E;padding:6px;border-radius:4px;font-family:monospace;margin-bottom:10px;">
<label style="display:block;font-size:11px;color:#8888AA;margin-bottom:4px;">Check-Intervall (Sek, min 30)</label>
<input type="number" id="trigger-check-interval" min="30" max="86400" value="300" style="width:100%;background:#0D0D1A;color:#E0E0F0;border:1px solid #1E1E2E;padding:6px;border-radius:4px;font-family:inherit;margin-bottom:10px;">
<label style="display:block;font-size:11px;color:#8888AA;margin-bottom:4px;">Throttle zwischen Feuerungen (Sek)</label>
<input type="number" id="trigger-throttle" min="0" max="86400" value="3600" style="width:100%;background:#0D0D1A;color:#E0E0F0;border:1px solid #1E1E2E;padding:6px;border-radius:4px;font-family:inherit;margin-bottom:10px;">
<div id="trigger-vars-info" style="font-size:10px;color:#555570;line-height:1.6;margin-bottom:10px;"></div>
</div>
<label style="display:block;font-size:11px;color:#8888AA;margin-bottom:4px;">Nachricht</label>
<textarea id="trigger-message" rows="3" placeholder="Was soll ARIA sagen wenn der Trigger feuert?" style="width:100%;background:#0D0D1A;color:#E0E0F0;border:1px solid #1E1E2E;padding:6px;border-radius:4px;font-family:inherit;resize:vertical;margin-bottom:10px;"></textarea>
<div id="trigger-modal-error" style="color:#FF6B6B;font-size:11px;margin-top:4px;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;">
<button class="btn secondary" onclick="closeTriggerModal()">Abbrechen</button>
<button class="btn" onclick="saveTrigger()">Anlegen</button>
</div>
</div>
</div>
<!-- Generisches Info-Modal — wird via openInfoModal(title, html) gefuellt -->
<div class="modal-overlay" id="info-modal">
<div class="modal-box" style="max-width:640px;">
@@ -1274,6 +1343,14 @@
}
return;
}
if (msg.type === 'trigger_created') {
addTriggerCreatedBubble(msg.payload || {});
// Falls Triggers-Tab offen: refreshen
if (document.getElementById('tab-triggers') && document.getElementById('tab-triggers').classList.contains('visible')) {
loadTriggers();
}
return;
}
if (msg.type === 'chat_delta') { return; }
if (msg.type === 'chat_error') {
addChat('error', msg.error, 'chat:error');
@@ -1282,10 +1359,20 @@
if (msg.type === 'rvs_chat') {
const p = msg.msg.payload || {};
const sender = p.sender || '?';
// ARIA-Antworten kommen schon via Gateway (chat:final) — nicht nochmal via RVS anzeigen
if (sender === 'aria') return;
const chatType = 'sent';
const label = sender === 'stt' ? '\uD83C\uDFA4 Spracheingabe' : `via RVS (${sender})`;
// Frueher: 'aria' kam parallel via OpenClaw-Gateway (chat:final) UND via RVS,
// RVS wurde dedupliziert. Gateway ist raus — ARIA-Antworten kommen jetzt
// ausschliesslich via RVS, also nicht mehr blocken.
let chatType, label;
if (sender === 'aria') {
chatType = 'received';
label = 'ARIA';
} else if (sender === 'stt') {
chatType = 'sent';
label = '\uD83C\uDFA4 Spracheingabe';
} else {
chatType = 'sent';
label = `via RVS (${sender})`;
}
addChat(chatType, p.text || '?', label, { location: p.location });
return;
}
@@ -1750,6 +1837,37 @@
}
}
/** ARIA hat einen Trigger angelegt — Bubble mit Details. */
function addTriggerCreatedBubble(trigger) {
const name = trigger.name || '(unbenannt)';
const ttype = trigger.type || 'timer';
const msg = trigger.message || '';
const detail = ttype === 'timer'
? `feuert: <code>${escapeHtml(trigger.fires_at || '?')}</code>`
: `wenn: <code>${escapeHtml(trigger.condition || '?')}</code>`;
const html = `
<div style="font-weight:bold;color:#FFD60A;">⏰ ARIA hat einen Trigger angelegt</div>
<div style="margin-top:4px;color:#E0E0F0;">
<strong>${escapeHtml(name)}</strong>
<span style="color:#8888AA;font-size:11px;margin-left:6px;">(${escapeHtml(ttype)})</span>
</div>
<div style="color:#8888AA;font-size:11px;margin-top:2px;">${detail}</div>
<div style="color:#8888AA;font-size:12px;margin-top:2px;">"${escapeHtml(msg)}"</div>
<div class="meta">
ARIA-Trigger — ${new Date().toLocaleTimeString('de-DE')} ·
<a href="#" onclick="event.preventDefault();switchMainTab('triggers');" style="color:#FFD60A;">im Trigger-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;
}
}
/** ARIA hat einen Skill erstellt — als auffaellige Bubble anzeigen. */
function addSkillCreatedBubble(skill) {
const name = skill.name || '(unbenannt)';
@@ -2664,6 +2782,167 @@
loadFiles();
} else if (tab === 'skills') {
loadSkills();
} else if (tab === 'triggers') {
loadTriggers();
}
}
// ── Triggers-Verwaltung ────────────────────────────────
let triggersCache = [];
async function loadTriggers() {
const el = document.getElementById('triggers-list');
if (!el) return;
try {
const r = await fetch('/api/brain/triggers/list');
if (!r.ok) throw new Error('HTTP ' + r.status);
const d = await r.json();
triggersCache = d.triggers || [];
renderTriggersList();
} catch (e) {
el.innerHTML = `🔴 Brain nicht erreichbar (${e.message})`;
}
}
function renderTriggersList() {
const el = document.getElementById('triggers-list');
if (!el) return;
if (!triggersCache.length) {
el.innerHTML = '<div style="padding:8px;color:#555570;">Keine Trigger vorhanden. Sag ARIA "erinner mich in 5 Minuten" oder leg manuell einen an.</div>';
return;
}
const fmtDate = (iso) => iso ? new Date(iso).toLocaleString('de-DE') : '';
el.innerHTML = triggersCache.map(t => {
const active = t.active !== false;
const statusBadge = active
? '<span style="background:#34C75922;color:#34C759;padding:1px 6px;border-radius:3px;font-size:10px;">aktiv</span>'
: '<span style="background:#55557022;color:#888;padding:1px 6px;border-radius:3px;font-size:10px;">INAKTIV</span>';
const typeBadge = t.type === 'timer'
? '<span style="background:#FFD60A22;color:#FFD60A;padding:1px 6px;border-radius:3px;font-size:10px;">⏱ TIMER</span>'
: '<span style="background:#0096FF22;color:#0096FF;padding:1px 6px;border-radius:3px;font-size:10px;">👁 WATCHER</span>';
const authorBadge = t.author === 'aria'
? '<span style="background:#FFD60A22;color:#FFD60A;padding:1px 6px;border-radius:3px;font-size:10px;">von ARIA</span>'
: '';
let detailLine = '';
if (t.type === 'timer') {
detailLine = `feuert: <code>${escapeHtml(t.fires_at || '?')}</code>`;
} else if (t.type === 'watcher') {
detailLine = `wenn: <code>${escapeHtml(t.condition || '?')}</code> · check alle ${t.check_interval_sec}s · throttle ${t.throttle_sec}s`;
}
return `
<div style="border-bottom:1px solid #1E1E2E;padding:8px 0;">
<div style="display:flex;align-items:center;gap:8px;">
<span style="flex:1;color:#E0E0F0;font-weight:bold;">${escapeHtml(t.name)}</span>
${statusBadge} ${typeBadge} ${authorBadge}
<span style="color:#555570;font-size:10px;">${t.fire_count || 0}× · zuletzt ${fmtDate(t.last_fired_at)}</span>
</div>
<div style="color:#8888AA;font-size:11px;margin-top:4px;">${detailLine}</div>
<div style="color:#888;font-size:12px;margin-top:2px;">"${escapeHtml(t.message || '')}"</div>
<div style="margin-top:6px;display:flex;gap:6px;">
<button class="btn secondary" onclick="toggleTriggerActive('${escapeHtml(t.name)}', ${!active})" style="padding:2px 10px;font-size:10px;color:#FF9500;border-color:#FF9500;">${active ? '⏸ Deaktivieren' : '▶ Aktivieren'}</button>
<button class="btn secondary" onclick="deleteTrigger('${escapeHtml(t.name)}')" style="padding:2px 10px;font-size:10px;color:#FF6B6B;border-color:#FF6B6B;">🗑 Löschen</button>
</div>
</div>
`;
}).join('');
}
async function toggleTriggerActive(name, newActive) {
try {
await fetch('/api/brain/triggers/' + encodeURIComponent(name), {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ active: newActive }),
});
loadTriggers();
} catch (e) {
alert('Toggle fehlgeschlagen: ' + e.message);
}
}
async function deleteTrigger(name) {
if (!confirm(`Trigger "${name}" wirklich löschen?`)) return;
try {
await fetch('/api/brain/triggers/' + encodeURIComponent(name), { method: 'DELETE' });
loadTriggers();
} catch (e) {
alert('Löschen fehlgeschlagen: ' + e.message);
}
}
function onTriggerTypeChange() {
const t = document.getElementById('trigger-type').value;
document.getElementById('trigger-timer-fields').style.display = t === 'timer' ? '' : 'none';
document.getElementById('trigger-watcher-fields').style.display = t === 'watcher' ? '' : 'none';
}
async function openTriggerCreate() {
document.getElementById('trigger-type').value = 'timer';
document.getElementById('trigger-name').value = '';
document.getElementById('trigger-timer-minutes').value = '10';
document.getElementById('trigger-condition').value = '';
document.getElementById('trigger-check-interval').value = '300';
document.getElementById('trigger-throttle').value = '3600';
document.getElementById('trigger-message').value = '';
document.getElementById('trigger-modal-error').style.display = 'none';
onTriggerTypeChange();
// Variablen-Hinweis laden
try {
const r = await fetch('/api/brain/triggers/conditions');
const d = await r.json();
const info = document.getElementById('trigger-vars-info');
if (info) {
info.innerHTML = '<strong>Variablen:</strong> ' + (d.variables || []).map(v =>
`<code>${escapeHtml(v.name)}</code> = ${escapeHtml(String(d.current[v.name]))} <span style="color:#444;">(${escapeHtml(v.desc)})</span>`
).join(' · ');
}
} catch {}
document.getElementById('trigger-modal').classList.add('open');
}
function closeTriggerModal() {
document.getElementById('trigger-modal').classList.remove('open');
}
async function saveTrigger() {
const errEl = document.getElementById('trigger-modal-error');
errEl.style.display = 'none';
const ttype = document.getElementById('trigger-type').value;
const name = document.getElementById('trigger-name').value.trim();
const message = document.getElementById('trigger-message').value.trim();
if (!name) { errEl.textContent = 'Name fehlt.'; errEl.style.display = 'block'; return; }
if (!message) { errEl.textContent = 'Nachricht fehlt.'; errEl.style.display = 'block'; return; }
try {
let url, body;
if (ttype === 'timer') {
const mins = parseInt(document.getElementById('trigger-timer-minutes').value, 10) || 10;
const firesAt = new Date(Date.now() + mins * 60 * 1000).toISOString();
url = '/api/brain/triggers/timer';
body = { name, fires_at: firesAt, message, author: 'stefan' };
} else {
const condition = document.getElementById('trigger-condition').value.trim();
if (!condition) { errEl.textContent = 'Condition fehlt.'; errEl.style.display = 'block'; return; }
url = '/api/brain/triggers/watcher';
body = {
name, condition, message, author: 'stefan',
check_interval_sec: parseInt(document.getElementById('trigger-check-interval').value, 10) || 300,
throttle_sec: parseInt(document.getElementById('trigger-throttle').value, 10) || 3600,
};
}
const r = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!r.ok) {
const t = await r.text();
throw new Error('HTTP ' + r.status + ': ' + t.slice(0, 200));
}
closeTriggerModal();
loadTriggers();
} catch (e) {
errEl.textContent = e.message;
errEl.style.display = 'block';
}
}
@@ -3525,6 +3804,20 @@
<p><strong>Warn-Schwellen:</strong> 5h-Counter wird gelb bei 80%, rot bei 90% des Plan-Limits.</p>
`,
},
'triggers': {
title: 'Trigger — passive Aufweck-Quellen',
html: `
<p><strong>Skills</strong> sind aktiv (ARIA ruft sie auf).
<strong>Trigger</strong> sind passiv: das System ruft ARIA wenn ein Event passiert.</p>
<p><strong>Timer:</strong> einmalig zu festem Zeitpunkt. "Erinner mich in 10min" → ARIA legt einen Timer an.</p>
<p><strong>Watcher:</strong> pruefen alle paar Minuten eine Bedingung (z.B. <code>disk_free_gb &lt; 5</code>),
feuern wenn wahr. Throttle verhindert Spam (Default: max 1× pro Stunde).</p>
<p><strong>Token-Effizienz:</strong> das Polling laeuft lokal im Brain-Container ohne Claude-Calls.
Erst wenn ein Trigger tatsaechlich feuert, wird ARIA aufgeweckt und antwortet.</p>
<p><strong>Wer legt sie an:</strong> entweder du (Diagnostic-Tab + Neu) oder ARIA selbst auf deinen Wunsch
im Chat ("sag bescheid wenn Disk unter 5GB").</p>
`,
},
'bootstrap': {
title: 'Bootstrap & Migration — die drei Wege',
html: `
+2 -1
View File
@@ -239,7 +239,8 @@ Skills mit Tool-Use.
- [x] Memory-Destillat: bei >60 Turns automatisch 30 aelteste → fact-Memories via Claude-Call
- [x] Hot Memory (pinned) + Cold Memory (Top-5 semantisch) im System-Prompt
- [x] Manueller Destillat-Trigger + Konversation-Reset (Brain + Diagnostic chat_backup gleichzeitig)
- [x] App-Chat-Sync: verpasste Nachrichten beim Reconnect + chat_cleared Live-Update
- [x] Bridge schreibt chat_backup.jsonl bei jedem Turn (User + ARIA + ARIA-Files)
- [x] App-Chat-Sync: kompletter Server-Sync bei Reconnect (Server = Source of Truth). Wenn Server leer → App leert auch. Lokal-only Bubbles (Skill-Notifications, laufende Voice ohne STT) bleiben erhalten. Plus chat_cleared Live-Update wenn Diagnostic die History wiped.
### Skills-System (Phase B Punkt 4)
+1
View File
@@ -25,6 +25,7 @@ const ALLOWED_TYPES = new Set([
"xtts_export_voice", "xtts_voice_exported",
"xtts_import_voice", "xtts_voice_imported",
"skill_created",
"trigger_created",
"chat_history_request", "chat_history_response", "chat_cleared",
"file_delete_batch_request", "file_delete_batch_response",
"file_zip_request", "file_zip_response",