Compare commits

..
8 Commits
Author SHA1 Message Date
duffyduck a2b7e3a48d release: bump version to 0.2.4.2 2026-08-16 11:26:49 +02:00
duffyduckandClaude Opus 4.8 7b72149671 fix(voxtral): Halluzinations-Filter gegen Phantom-Text aus Stille
Punkt 3: 2. Netz nach der Transkription. Im borderline-Band (wenig echte
Stimme) werden leere/Artefakt-Transkripte verworfen statt als Phantom ans
Brain zu gehen ('Die Stadt hat eine Fläche von 1,5 km²' aus Fast-Stille).
Bekannte Voxtral-Silence-Artefakte (Untertitel-Credits, Geo-/Städte-Fakten)
per Regex, gegated auf voiced_frames — echte Geo-FRAGEN (normale Energie)
gehen durch.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-16 11:19:06 +02:00
duffyduckandClaude Opus 4.8 e9439dbccb feat(app): Hintergrund-Wake als Schalter (default aus)
Punkt 2: das Wake-Wort triggerte nur im Vordergrund — eine bewusste JS-Zeile
verwarf jede Hintergrund-Erkennung (native Erkennung + Foreground-Service
liefen längst durch). Jetzt hinter Einstellung 'Auch bei gesperrtem Bildschirm
zuhören' (default aus, mehr Fehltrigger möglich). Greift live via
setBgWakeEnabled.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-16 11:19:06 +02:00
duffyduckandClaude Opus 4.8 219de091d2 feat(brain): Titel-Index fürs kalte Gedächtnis + 'beendet' erkennen
Punkt 4B: jeder System-Prompt bekommt einen kompakten Titel-Index der bewusst
gespeicherten Nachschlage-Memories (Zugangsdaten, Infra, Projekte) — ARIA
sieht WAS sie hat und holt es via memory_search, statt Stefan nach etwas zu
fragen, das schon da ist (Git-Credentials-Vorfall). Auto-distillierte Fakten
+ Conversation-Logs sind ausgefiltert → billig.

Punkt 1: _CONV_END_VERB erkennt jetzt auch 'beendet' (beend\w*) und 'stoppe'
(stop\w*) — 'Konversation beendet' schloss vorher das Mikro nicht.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-16 11:19:06 +02:00
duffyduckandClaude Opus 4.8 5992a7e441 feat(brain): Gedächtnis nach scope trennen (system/personal)
Neues Feld scope=system|personal auf jedem Memory-Punkt. Bootstrap-Export
getrennt: System-Regeln (generisch, teilbar) vs. Persönliches (Name,
Zugangsdaten, Projekte). Import ist scope-sicher — ein System-Import löscht
NICHT die persönlichen pinned Memories. seed_rules + AGENT.md/TOOLING.md →
system, USER.md-Präferenzen → personal. Backfill für Bestand (57 system /
617 personal). Diagnostic: zwei Export-Buttons, scope-Badge (SYS/PRIV) +
Umschalter pro Memory.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-16 11:19:06 +02:00
duffyduck 07ccf05429 release: bump version to 0.2.4.1 2026-08-16 02:02:10 +02:00
duffyduckandClaude Opus 4.8 4ab0e68245 fix(voice): Passiv-Backstop lang genug — schneidet lange Antworten nicht mehr ab
Log-Analyse (alter Build): eine lange gesprochene Antwort wurde vom 30s-Passiv-
Timer mitten im Wort gekappt (exit reason=timeout, dann stt_endpoint reason=
stream_end mit abgeschnittenem Text). Genau das Fenster ist raus — ABER mein
Ersatz-Backstop (15s) hätte sogar früher abgeschnitten.

Der Backstop ist eine reine HANG-Notbremse und darf aktives Reden NIE kappen →
jetzt 10min (länger als der ~5min-Hardcap der Aufnahme). Das echte Ende regelt
immer die Aufnahme selbst (Stille-Toleranz / No-Speech / Hardcap). Lange
zusammenhängende Antworten laufen jetzt durch, bis du ≥ Stille-Toleranz pausierst.

Hinweis: der geloggte Fehler ist der ALTE Build — die Fixes sind noch nicht
ausgerollt. Dieser Commit korrigiert den noch-nicht-deployten Stand.

Deploy: neue APK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-16 01:59:45 +02:00
duffyduckandClaude Opus 4.8 9636a702d3 refactor(voice): tote Fenster-Settings ausgeräumt (Konv-Fenster/Passiv-Lauschen)
Nachdem die Stille-Toleranz jetzt überall der einzige Wert ist, sind die alten
Settings obsolet — raus damit:

- audio.ts: CONV_WINDOW_* Konstanten + loadConvWindowMs() entfernt.
- wakeword.ts: PASSIVE_LISTEN_* + load/savePassiveListenMs() entfernt; der Passiv-
  Master-Timer nutzt jetzt einen festen internen Backstop (PASSIVE_BACKSTOP_MS,
  15s) statt eines Nutzer-Werts — das echte Ende regelt die Stille-Toleranz.
- SettingsScreen: "Konversations-Fenster"- und "Weiterreden-Fenster"-Slider raus;
  Letzterer durch eine kurze Erklärung ersetzt (verweist auf Stille-Toleranz).
- ChatScreen: tote Imports weg.

Kein Verhaltensänderung ggü. ebe0e80 — nur Aufräumen. tsc grün.

Deploy: neue APK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-16 01:53:55 +02:00
15 changed files with 418 additions and 175 deletions
+2 -2
View File
@@ -79,8 +79,8 @@ android {
applicationId "com.ariacockpit"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 20400
versionName "0.2.4.0"
versionCode 20402
versionName "0.2.4.2"
// Fallback fuer Libraries mit Product Flavors
missingDimensionStrategy 'react-native-camera', 'general'
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "aria-cockpit",
"version": "0.2.4.0",
"version": "0.2.4.2",
"private": true,
"scripts": {
"android": "react-native run-android",
+2 -2
View File
@@ -35,7 +35,7 @@ import MemoryBrowser from '../components/MemoryBrowser';
import ErrorBoundary from '../components/ErrorBoundary';
import rvs, { RVSMessage, ConnectionState } from '../services/rvs';
import audioService from '../services/audio';
import wakeWordService, { loadPassiveListenMs } from '../services/wakeword';
import wakeWordService from '../services/wakeword';
import ProjectsBrowser from '../components/ProjectsBrowser';
import brainApi, { Project as BrainProject } from '../services/brainApi';
import projectFocus from '../services/projectFocus';
@@ -50,7 +50,7 @@ import VoiceButton from '../components/VoiceButton';
import FileUpload, { FileData } from '../components/FileUpload';
import CameraUpload, { PhotoData } from '../components/CameraUpload';
import MessageText from '../components/MessageText';
import { loadConvWindowMs, loadTtsSpeed, TTS_SPEED_DEFAULT, loadSttEndpointMs, loadMaxRecordingMs, loadBargeInEnabled } from '../services/audio';
import { loadTtsSpeed, TTS_SPEED_DEFAULT, loadSttEndpointMs, loadMaxRecordingMs, loadBargeInEnabled } from '../services/audio';
import Geolocation from '@react-native-community/geolocation';
// --- Typen ---
+33 -81
View File
@@ -63,10 +63,6 @@ import {
VAD_SILENCE_MIN_SEC,
VAD_SILENCE_MAX_SEC,
VAD_SILENCE_STORAGE_KEY,
CONV_WINDOW_DEFAULT_SEC,
CONV_WINDOW_MIN_SEC,
CONV_WINDOW_MAX_SEC,
CONV_WINDOW_STORAGE_KEY,
STT_ENDPOINT_DEFAULT_MS,
STT_ENDPOINT_MIN_MS,
STT_ENDPOINT_MAX_MS,
@@ -116,9 +112,8 @@ import wakeWordService, {
WAKE_THRESHOLD_MAX,
loadWakeThreshold,
saveWakeThreshold,
PASSIVE_LISTEN_DEFAULT_MS,
loadPassiveListenMs,
savePassiveListenMs,
loadBgWakeEnabled,
saveBgWakeEnabled,
} from '../services/wakeword';
import ModeSelector from '../components/ModeSelector';
import QRScanner from '../components/QRScanner';
@@ -204,7 +199,6 @@ const SettingsScreen: React.FC = () => {
// Aktive Streaming-Pausen-Toleranz (STT_ENDPOINT) — der "Stille-Toleranz"-Regler
// steuert jetzt DIESEN Wert (der alte vadSilenceSec war der tote Legacy-dB-Pfad).
const [sttEndpointSec, setSttEndpointSec] = useState<number>(STT_ENDPOINT_DEFAULT_MS / 1000);
const [convWindowSec, setConvWindowSec] = useState<number>(CONV_WINDOW_DEFAULT_SEC);
const [maxRecordingSec, setMaxRecordingSec] = useState<number>(MAX_RECORDING_DEFAULT_SEC);
// Barge-in: ARIA waehrend ihrer Antwort unterbrechen duerfen. Default aus (Halb-Duplex).
const [bargeIn, setBargeIn] = useState<boolean>(false);
@@ -220,7 +214,8 @@ const SettingsScreen: React.FC = () => {
const [wakeStatus, setWakeStatus] = useState<string>('');
const [wakeReadySound, setWakeReadySound] = useState<boolean>(true);
const [wakeThreshold, setWakeThreshold] = useState<number>(WAKE_THRESHOLD_DEFAULT);
const [passiveSec, setPassiveSec] = useState<number>(Math.round(PASSIVE_LISTEN_DEFAULT_MS / 1000));
// Hintergrund-Wake: auch bei gesperrtem Bildschirm auf das Wake-Wort hoeren. Default aus.
const [bgWake, setBgWake] = useState<boolean>(false);
const [editingPath, setEditingPath] = useState(false);
const [xttsVoice, setXttsVoice] = useState('');
const [loadingVoice, setLoadingVoice] = useState<string | null>(null);
@@ -309,14 +304,6 @@ const SettingsScreen: React.FC = () => {
}
}
});
AsyncStorage.getItem(CONV_WINDOW_STORAGE_KEY).then(saved => {
if (saved != null) {
const n = parseFloat(saved);
if (isFinite(n) && n >= CONV_WINDOW_MIN_SEC && n <= CONV_WINDOW_MAX_SEC) {
setConvWindowSec(n);
}
}
});
AsyncStorage.getItem(STT_ENDPOINT_STORAGE_KEY).then(saved => {
if (saved != null) {
const n = parseInt(saved, 10);
@@ -353,7 +340,7 @@ const SettingsScreen: React.FC = () => {
});
isWakeReadySoundEnabled().then(setWakeReadySound);
loadWakeThreshold().then(setWakeThreshold).catch(() => {});
loadPassiveListenMs().then(ms => setPassiveSec(Math.round(ms / 1000))).catch(() => {});
loadBgWakeEnabled().then(setBgWake).catch(() => {});
updateService.getApkCacheSize().then(setApkCacheInfo).catch(() => {});
audioService.getTtsCacheSize().then(setTtsCacheInfo).catch(() => {});
AsyncStorage.getItem('aria_xtts_voice').then(saved => {
@@ -1721,39 +1708,6 @@ const SettingsScreen: React.FC = () => {
</TouchableOpacity>
</View>
<Text style={[styles.toggleLabel, {marginTop: 24}]}>Konversations-Fenster</Text>
<Text style={styles.toggleHint}>
Im Gespraechsmodus (Ohr-Button): nach ARIA's Antwort hast du so lange
Zeit, weiter zu sprechen, bevor die Konversation automatisch beendet wird.
Sprichst du nichts Mikrofon zu.
Default: {CONV_WINDOW_DEFAULT_SEC.toFixed(1)}s.
</Text>
<View style={styles.prerollRow}>
<TouchableOpacity
style={styles.prerollButton}
onPress={() => {
const next = Math.max(CONV_WINDOW_MIN_SEC, Math.round((convWindowSec - 1) * 10) / 10);
setConvWindowSec(next);
AsyncStorage.setItem(CONV_WINDOW_STORAGE_KEY, String(next));
}}
disabled={convWindowSec <= CONV_WINDOW_MIN_SEC}
>
<Text style={styles.prerollButtonText}>1</Text>
</TouchableOpacity>
<Text style={styles.prerollValue}>{convWindowSec.toFixed(0)} s</Text>
<TouchableOpacity
style={styles.prerollButton}
onPress={() => {
const next = Math.min(CONV_WINDOW_MAX_SEC, Math.round((convWindowSec + 1) * 10) / 10);
setConvWindowSec(next);
AsyncStorage.setItem(CONV_WINDOW_STORAGE_KEY, String(next));
}}
disabled={convWindowSec >= CONV_WINDOW_MAX_SEC}
>
<Text style={styles.prerollButtonText}>+1</Text>
</TouchableOpacity>
</View>
<Text style={[styles.toggleLabel, {marginTop: 24}]}>Maximale Aufnahmedauer</Text>
<Text style={styles.toggleHint}>
Notbremse: nach so vielen Minuten wird die Aufnahme automatisch beendet,
@@ -1945,38 +1899,36 @@ const SettingsScreen: React.FC = () => {
/>
</View>
<Text style={[styles.toggleLabel, {marginTop: 20}]}>Weiterreden-Fenster (Gespraech)</Text>
<Text style={styles.toggleHint}>
Nach einer gesprochenen ARIA-Antwort kannst du so lange einfach
weiterreden — ohne Wake-Word — bevor zurueck aufs Wake-Word geschaltet
wird. Reine Steuerbefehle (z.B. „nächster Titel") beenden sofort.
Default: {Math.round(PASSIVE_LISTEN_DEFAULT_MS / 1000)}s.
</Text>
<View style={styles.prerollRow}>
<TouchableOpacity
style={styles.prerollButton}
onPress={() => {
const next = Math.max(10, passiveSec - 5);
setPassiveSec(next);
savePassiveListenMs(next * 1000);
<View style={[styles.toggleRow, {marginTop: 20, borderTopWidth: 1, borderTopColor: '#1E1E2E', paddingTop: 16}]}>
<View style={styles.toggleInfo}>
<Text style={styles.toggleLabel}>Auch bei gesperrtem Bildschirm zuhören</Text>
<Text style={styles.toggleHint}>
AUS (empfohlen): das Wake-Wort greift nur, wenn die App offen ist
im Hintergrund sind die meisten Trigger" Fehlalarme (TV, Husten).
AN: ARIA hört auch bei gesperrtem Bildschirm / im Hintergrund auf
„{KEYWORD_LABELS[wakeKeyword as keyof typeof KEYWORD_LABELS] || wakeKeyword}" mehr Fehlauslöser möglich.
</Text>
</View>
<Switch
value={bgWake}
onValueChange={(val) => {
setBgWake(val);
saveBgWakeEnabled(val).catch(() => {});
wakeWordService.setBgWakeEnabled(val);
}}
disabled={passiveSec <= 10}
>
<Text style={styles.prerollButtonText}>5</Text>
</TouchableOpacity>
<Text style={styles.prerollValue}>{passiveSec} s</Text>
<TouchableOpacity
style={styles.prerollButton}
onPress={() => {
const next = Math.min(60, passiveSec + 5);
setPassiveSec(next);
savePassiveListenMs(next * 1000);
}}
disabled={passiveSec >= 60}
>
<Text style={styles.prerollButtonText}>+5</Text>
</TouchableOpacity>
trackColor={{ false: '#2A2A3E', true: '#0096FF' }}
thumbColor={bgWake ? '#FFFFFF' : '#666680'}
/>
</View>
<Text style={[styles.toggleLabel, {marginTop: 20}]}>Weiterreden nach der Antwort</Text>
<Text style={styles.toggleHint}>
Nach einer gesprochenen ARIA-Antwort geht das Mikro auf du kannst ohne
Wake-Word weiterreden. Fängst du nicht innerhalb der Stille-Toleranz"
(Sektion Spracheingabe) an, geht's zurück aufs Wake-Word. Reine
Steuerbefehle beenden sofort. Ein separates Zeitfenster gibt es nicht
mehr — es zählt überall derselbe Stille-Wert.
</Text>
</View>
</>)}
-20
View File
@@ -143,14 +143,6 @@ export const VAD_SILENCE_MIN_SEC = 1.0;
export const VAD_SILENCE_MAX_SEC = 8.0;
export const VAD_SILENCE_STORAGE_KEY = 'aria_vad_silence_sec';
// Konversations-Fenster (in Sekunden) — nach ARIA's Antwort hat der User so
// lange Zeit, im Gespraechsmodus weiter zu sprechen, ohne dass die Konversation
// beendet wird. Sprichst du im Fenster nichts → Konversation aus.
export const CONV_WINDOW_DEFAULT_SEC = 8.0;
export const CONV_WINDOW_MIN_SEC = 3.0;
export const CONV_WINDOW_MAX_SEC = 20.0;
export const CONV_WINDOW_STORAGE_KEY = 'aria_conv_window_sec';
// STT-Endpoint (ms Stille bis "fertig gesprochen"). Zu kurz = schneidet mitten
// im Satz ab, besonders im Auto oder wenn man zum Nachdenken pausiert. 1500 war
// zu aggressiv; 2400 default, bis 8s hoch stellbar (Denkpausen). In den Settings
@@ -208,18 +200,6 @@ export async function loadTtsSpeed(): Promise<number> {
return TTS_SPEED_DEFAULT;
}
export async function loadConvWindowMs(): Promise<number> {
try {
const raw = await AsyncStorage.getItem(CONV_WINDOW_STORAGE_KEY);
if (raw != null) {
const n = parseFloat(raw);
if (isFinite(n) && n >= CONV_WINDOW_MIN_SEC && n <= CONV_WINDOW_MAX_SEC) {
return Math.round(n * 1000);
}
}
} catch {}
return Math.round(CONV_WINDOW_DEFAULT_SEC * 1000);
}
async function loadVadSilenceMs(): Promise<number> {
try {
+59 -37
View File
@@ -30,26 +30,14 @@ type PassiveListenCallback = () => void;
export type WakeWordState = 'off' | 'armed' | 'conversing' | 'listening';
/** Default-Dauer fuer den Passive-Listen-Modus nach einer Konversation —
* in dem Fenster braucht's kein Wake-Word, Speaker-ID-Filter haelt
* fremde Stimmen raus (TV, Familie). 30s default; konfigurierbar. */
export const PASSIVE_LISTEN_DEFAULT_MS = 30_000;
export const PASSIVE_LISTEN_STORAGE_KEY = 'aria_passive_listen_ms';
export async function loadPassiveListenMs(): Promise<number> {
try {
const raw = await AsyncStorage.getItem(PASSIVE_LISTEN_STORAGE_KEY);
if (raw) {
const n = parseInt(raw, 10);
if (isFinite(n) && n >= 0 && n <= 120_000) return n;
}
} catch {}
return PASSIVE_LISTEN_DEFAULT_MS;
}
export async function savePassiveListenMs(ms: number): Promise<void> {
await AsyncStorage.setItem(PASSIVE_LISTEN_STORAGE_KEY, String(ms));
}
/** Reine HANG-Notbremse fuer den Passive-Listen-Modus. Das echte Ende regelt IMMER
* die passive Aufnahme selbst: Stille-Toleranz (User pausiert), No-Speech (User
* sagt gar nichts) oder Hard-Cap (max. Aufnahmedauer, ~5min) → ChatScreen ruft
* dann exitPassiveListening. Dieser Timer darf aktives Reden NIE abschneiden —
* deshalb LÄNGER als der Hard-Cap (nur falls ein Endpoint-Event mal verloren geht
* und der State sonst ewig 'listening' bliebe). Das alte 30s-Fenster, das lange
* Antworten mitten im Satz kappte, ist damit raus. */
const PASSIVE_BACKSTOP_MS = 10 * 60_000;
export const WAKE_KEYWORD_STORAGE = 'aria_wake_keyword';
@@ -77,6 +65,28 @@ export async function saveWakeThreshold(v: number): Promise<void> {
await AsyncStorage.setItem(WAKE_THRESHOLD_STORAGE_KEY, String(v));
}
// Hintergrund-Wake: darf das Wake-Wort auch triggern, wenn die App im
// Hintergrund / der Bildschirm gesperrt ist? Default AUS — im Hintergrund
// sind die meisten „Trigger" Fehlalarme (TV, Husten, AudioFocus-Spikes).
// AN = auch bei gesperrtem Bildschirm zuhoeren. Die native Erkennung laeuft
// ohnehin durch (Foreground-Service + Wake-Locks) — dieser Schalter oeffnet
// nur das JS-Gate in onWakeDetected.
export const BG_WAKE_STORAGE_KEY = 'aria_bg_wake_enabled';
export async function loadBgWakeEnabled(): Promise<boolean> {
try {
return (await AsyncStorage.getItem(BG_WAKE_STORAGE_KEY)) === 'true';
} catch {
return false;
}
}
export async function saveBgWakeEnabled(enabled: boolean): Promise<void> {
try {
await AsyncStorage.setItem(BG_WAKE_STORAGE_KEY, String(enabled));
} catch {}
}
/** Verfuegbare Wake-Words — entsprechen den .onnx Dateien in
* android/app/src/main/assets/openwakeword/. Custom-Keywords (eigenes
* Training via openwakeword Notebook) muessen aktuell als Asset eingebaut
@@ -145,6 +155,10 @@ class WakeWordService {
* Hintergrund-Detections sind quasi immer false-positives (TV, Husten,
* AudioFocus-Switch beim Wechsel zu Musik etc.). */
private inBackground: boolean = false;
/** Wenn true: Wake-Wort triggert auch im Hintergrund / bei gesperrtem
* Bildschirm. Default false. Wird beim Arm aus AsyncStorage geladen und
* bei Aenderung in den Einstellungen via setBgWakeEnabled() aktualisiert. */
private bgWakeEnabled: boolean = false;
/** Re-Entry-Guard fuer onWakeDetected: native kann mehrere
* WakeWordDetected-Events emitten BEVOR OpenWakeWord.stop() in JS
* resolved (Bridge-Queue + Doze-Backlog). Mit dem Flag wird das zweite
@@ -152,8 +166,9 @@ class WakeWordService {
* Ausnahme: bargeListening → Barge-In ist ein legitimer neuer Trigger
* waehrend ARIA noch redet, NICHT vom Guard blockieren. */
private detectionInProgress: boolean = false;
/** Passive-Listen-Timer: feuert nach PASSIVE_LISTEN_MS ohne Stefan-Speech,
* beendet den listening-State und geht zurueck zu armed. */
/** Passive-Listen-Backstop-Timer: Notbremse (PASSIVE_BACKSTOP_MS). Normal endet
* das Fenster ueber die Stille-Toleranz der Aufnahme; feuert dieser Timer
* trotzdem, zurueck zu armed. */
private passiveListenTimer: ReturnType<typeof setTimeout> | null = null;
/** Callbacks fuer den Eintritt in Passive-Listen — ChatScreen startet
* hier eine streaming-Aufnahme OHNE User-Bubble (passiv lauschen). */
@@ -225,7 +240,8 @@ class WakeWordService {
this.initInProgress = (async () => {
try {
const threshold = await loadWakeThreshold();
console.log('[WakeWord] init mit threshold=%s', threshold);
this.bgWakeEnabled = await loadBgWakeEnabled();
console.log('[WakeWord] init mit threshold=%s, bgWake=%s', threshold, this.bgWakeEnabled);
await OpenWakeWord.init(this.keyword, threshold, DEFAULT_PATIENCE, DEFAULT_DEBOUNCE_MS);
// Subscribe nur einmal
if (!this.eventSub) {
@@ -322,7 +338,14 @@ class WakeWordService {
* was als „Wake-Word" reinkommt ist Husten/TV/AudioFocus-Switch. */
setBackground(): void {
this.inBackground = true;
console.log('[WakeWord] App im Hintergrund — Detections gesperrt');
console.log('[WakeWord] App im Hintergrund — Detections %s',
this.bgWakeEnabled ? 'AKTIV (Hintergrund-Wake an)' : 'gesperrt');
}
/** Hintergrund-Wake ein/aus schalten (aus den Einstellungen). */
setBgWakeEnabled(enabled: boolean): void {
this.bgWakeEnabled = enabled;
console.log('[WakeWord] Hintergrund-Wake = %s', enabled);
}
/** App im Vordergrund: Detections wieder freigeben, plus kurzer Cooldown
@@ -337,9 +360,9 @@ class WakeWordService {
/** Wake-Word getriggert: Native-Modul pausieren, Konversation starten. */
private async onWakeDetected(): Promise<void> {
if (this.inBackground) {
console.log('[WakeWord] Trigger ignoriert (App im Hintergrund)');
import('./logger').then(m => m.reportAppDebug('wake.detect', 'ignored: app in background')).catch(()=>{});
if (this.inBackground && !this.bgWakeEnabled) {
console.log('[WakeWord] Trigger ignoriert (App im Hintergrund, Hintergrund-Wake aus)');
import('./logger').then(m => m.reportAppDebug('wake.detect', 'ignored: app in background (bg-wake off)')).catch(()=>{});
return;
}
// Re-Entry-Guard: blocken wenn ein Detection-Zyklus schon laeuft.
@@ -489,13 +512,12 @@ class WakeWordService {
import('./logger').then(m => m.reportAppDebug('wake.end',
`endConversation called, wasBarge=${wasBarge}, nativeReady=${this.nativeReady}`)).catch(()=>{});
// Passive-Listen aktiv? Dann nicht direkt zu armed — passive lauschen
// fuer N Sekunden, dann erst Wake-Word wieder aktivieren. Speaker-ID
// (Phase 3) filtert fremde Stimmen weg, der User kann ohne erneute
// Anrede weitersprechen.
const passiveMs = await loadPassiveListenMs();
if (!skipPassive && passiveMs > 0 && this.nativeReady) {
this.enterPassiveListening(passiveMs);
// Kein skipPassive? Dann EIN Stille-Fenster zum Weiterreden (kein Wake-Word
// noetig). Das echte Ende regelt die Stille-Toleranz der passiven Aufnahme;
// der Backstop-Timer ist nur die Notbremse. Der User kann ohne erneute
// Anrede weitersprechen; sagt er nichts → zurueck aufs Wake-Word.
if (!skipPassive && this.nativeReady) {
this.enterPassiveListening(PASSIVE_BACKSTOP_MS);
return;
}
@@ -537,10 +559,10 @@ class WakeWordService {
this.cancelPassiveListenTimer();
this.setState('listening');
const seconds = Math.round(durationMs / 1000);
console.log('[WakeWord] Passive-Listen aktiv (%ds) — Speaker-ID gefiltert', seconds);
console.log('[WakeWord] Passive-Listen aktiv (Backstop %ds) — Speaker-ID gefiltert', seconds);
import('./logger').then(m => m.reportAppDebug('wake.passive',
`entered listening for ${seconds}s, cb-count=${this.passiveListenCallbacks.length}`)).catch(()=>{});
ToastAndroid.show(`🎧 ${seconds}s lauscht — sprich einfach weiter`, ToastAndroid.SHORT);
`entered listening (backstop ${seconds}s), cb-count=${this.passiveListenCallbacks.length}`)).catch(()=>{});
ToastAndroid.show('🎧 sprich einfach weiter', ToastAndroid.SHORT);
this.passiveListenTimer = setTimeout(() => {
this.passiveListenTimer = null;
this.exitPassiveListening('timeout').catch(() => {});
+13 -3
View File
@@ -1402,10 +1402,10 @@ def _extract_flow_markers(text: str) -> tuple:
# ([^.!?]{0,15}) in beliebiger Reihenfolge; "befehls?kette" damit "Lieferkette"
# o.ae. nicht faelschlich matcht.
_CONV_NOUN = r"(?:konversation|gespr[aä]ch|befehls?kette)"
_CONV_END_VERB = r"(?:ende|beenden|beende|aus|stop|stopp|schluss)"
_CONV_END_VERB = r"(?:ende|beend\w*|aus|stop\w*|schluss)"
_END_CONVERSATION_RE = re.compile(
rf"\b{_CONV_NOUN}\b[^.!?]{{0,15}}\b{_CONV_END_VERB}\b"
rf"|\b(?:beende|schlie(?:ß|ss)e?)\b[^.!?]{{0,15}}\b{_CONV_NOUN}\b",
rf"|\b(?:beend\w*|schlie(?:ß|ss)\w*)\b[^.!?]{{0,15}}\b{_CONV_NOUN}\b",
re.IGNORECASE,
)
@@ -1929,6 +1929,15 @@ class Agent:
logger.warning("Cold-Search fehlgeschlagen: %s", exc)
cold = []
# 3b. Titel-Index des kalten Gedaechtnisses — ARIA sieht WAS sie an
# Nachschlage-Wissen hat (Zugangsdaten, Infra, Projekte) und holt es via
# memory_search, statt Stefan danach zu fragen. Nur Titel = billig.
try:
memory_index = self.store.list_index_titles()
except Exception as exc:
logger.warning("Titel-Index laden fehlgeschlagen: %s", exc)
memory_index = []
# 4. Aktive Skills holen + Tool-Liste bauen
all_skills = skills_mod.list_skills(active_only=False)
active_skills = [s for s in all_skills if s.get("active", True)]
@@ -1951,7 +1960,8 @@ class Agent:
oauth_port = os.environ.get("RVS_PORT_PUBLIC", os.environ.get("RVS_PORT", "443")).strip()
oauth_tls = os.environ.get("RVS_TLS", "true").strip().lower() != "false"
system_prompt = build_system_prompt(hot, cold, skills=all_skills,
system_prompt = build_system_prompt(hot, cold, memory_index=memory_index,
skills=all_skills,
triggers=all_triggers,
condition_vars=condition_vars,
condition_funcs=condition_funcs,
+90
View File
@@ -0,0 +1,90 @@
"""Einmaliger Backfill: weist bestehenden Memory-Punkten ein `scope`
(system | personal) zu. Sicher & reversibel — Stefan kann pro Eintrag in der
Diagnostic-UI umschalten. Idempotent: laeuft mehrfach ohne Schaden.
Heuristik (datengetrieben aus dem realen Bestand):
- type=preference / fact / conversation / reminder -> personal
- source in (seed, auto-feedback) -> system
- type=identity -> system
- type in (rule, tool, skill) und category in SYSTEM_CATS -> system
- sonst -> personal (sicher: nichts leakt)
Aufruf im Brain-Container:
docker exec aria-brain python3 /app/backfill_scope.py # dry-run
docker exec aria-brain python3 /app/backfill_scope.py --apply # schreibt
"""
import os
import sys
from collections import Counter
from qdrant_client import QdrantClient
from qdrant_client.http import models as qm
COLLECTION = "aria_memory"
SYSTEM_CATS = {
"sicherheit", "arbeitsweise", "architektur", "ehrlichkeit", "verhalten",
"voice", "skills", "freigaben", "infrastruktur", "persoenlichkeit",
"pentest", "ausgabe",
}
def compute_scope(pl: dict) -> str:
typ = pl.get("type")
src = pl.get("source")
cat = (pl.get("category") or "").lower()
if typ == "preference":
return "personal"
if typ in ("fact", "conversation", "reminder"):
return "personal"
if src in ("seed", "auto-feedback"):
return "system"
if typ == "identity":
return "system"
if typ in ("rule", "tool", "skill") and cat in SYSTEM_CATS:
return "system"
return "personal"
def main():
apply = "--apply" in sys.argv
force = "--force" in sys.argv # auch schon gesetzte scopes ueberschreiben
c = QdrantClient(
host=os.environ.get("QDRANT_HOST", "aria-qdrant"),
port=int(os.environ.get("QDRANT_PORT", "6333")),
)
pts, _ = c.scroll(collection_name=COLLECTION, limit=5000,
with_payload=True, with_vectors=False)
per_scope: dict[str, list] = {"system": [], "personal": []}
pinned_examples = Counter()
skipped = 0
for p in pts:
pl = p.payload or {}
if pl.get("scope") in ("system", "personal") and not force:
skipped += 1
continue
scope = compute_scope(pl)
per_scope[scope].append(p.id)
if pl.get("pinned"):
pinned_examples[(scope, pl.get("source"), pl.get("type"),
pl.get("category"))] += 1
print(f"total={len(pts)} skipped(already set)={skipped}")
print(f"-> system={len(per_scope['system'])} personal={len(per_scope['personal'])}")
print("pinned split (scope, source, type, category):")
for k, v in sorted(pinned_examples.items()):
print(" ", k, v)
if not apply:
print("\nDRY-RUN — nichts geschrieben. Mit --apply ausfuehren.")
return
for scope, ids in per_scope.items():
if not ids:
continue
c.set_payload(collection_name=COLLECTION, payload={"scope": scope}, points=ids)
print(f"\nAPPLIED: system={len(per_scope['system'])} personal={len(per_scope['personal'])}")
if __name__ == "__main__":
main()
+45 -13
View File
@@ -190,6 +190,7 @@ class MemoryIn(BaseModel):
pinned: bool = False
category: str = ""
source: str = "manual"
scope: str = "personal" # system | personal — steuert Bootstrap-Export
tags: List[str] = Field(default_factory=list)
conversation_id: Optional[str] = None
# Vorhandene Anhang-Metadaten beim Save mitgeben (i.d.R. werden Anhaenge
@@ -203,6 +204,7 @@ class MemoryUpdate(BaseModel):
content: Optional[str] = None
pinned: Optional[bool] = None
category: Optional[str] = None
scope: Optional[str] = None # system | personal
tags: Optional[List[str]] = None
@@ -214,6 +216,7 @@ class MemoryOut(BaseModel):
pinned: bool
category: str
source: str
scope: str = "personal"
tags: List[str]
created_at: str
updated_at: str
@@ -328,6 +331,7 @@ def memory_save(body: MemoryIn):
pinned=body.pinned,
category=body.category,
source=body.source,
scope=body.scope,
tags=body.tags,
conversation_id=body.conversation_id,
attachments=body.attachments or [],
@@ -353,6 +357,8 @@ def memory_update(point_id: str, body: MemoryUpdate):
existing.pinned = body.pinned
if body.category is not None:
existing.category = body.category
if body.scope is not None:
existing.scope = body.scope
if body.tags is not None:
existing.tags = body.tags
@@ -537,12 +543,23 @@ def memory_import_files():
# Wiederherstellen einer schlanken ARIA nach Wipe.
@app.get("/memory/export-bootstrap")
def memory_export_bootstrap():
"""Gibt alle pinned Memories als JSON zurueck — fuer Browser-Download."""
def memory_export_bootstrap(scope: str = "system"):
"""Gibt pinned Memories als JSON zurueck — fuer Browser-Download.
scope='system' → nur generische Regeln (fuer ein frisches System),
scope='personal' → nur Stefan-spezifisches (Name, Zugangsdaten, Projekte),
scope='all' → alles pinned (Vollbackup).
Default 'system', damit man nicht versehentlich Persoenliches teilt."""
s = store()
pinned = s.list_pinned()
if scope == "all":
pinned = s.list_pinned()
elif scope in ("system", "personal"):
pinned = s.list_pinned_by_scope(scope)
else:
raise HTTPException(400, f"Ungueltiger scope: {scope}")
return {
"version": 1,
"version": 2,
"scope": scope,
"exported_at": __import__("datetime").datetime.now(
__import__("datetime").timezone.utc
).isoformat(),
@@ -555,6 +572,7 @@ def memory_export_bootstrap():
"pinned": True,
"category": p.category,
"source": p.source,
"scope": p.scope,
"tags": p.tags,
}
for p in pinned
@@ -564,13 +582,18 @@ def memory_export_bootstrap():
class BootstrapBundle(BaseModel):
version: int = 1
scope: Optional[str] = None # system | personal | all (aus dem Export)
memories: List[dict]
@app.post("/memory/import-bootstrap")
def memory_import_bootstrap(body: BootstrapBundle):
"""Loescht alle pinned Memories und importiert die im Bundle.
Cold Memory (unpinned) bleibt unangetastet.
"""Importiert ein Bootstrap-Bundle scope-sicher.
Es werden NUR die aktuell pinned Punkte geloescht, deren scope zum Import
gehoert — ein System-Import laesst also die persoenlichen pinned Memories
(Name, Zugangsdaten) unangetastet und umgekehrt. Bei einem 'all'-Bundle
(Vollbackup) werden alle pinned ersetzt.
Wenn keine Memories im Bundle: nur loeschen ist NICHT erlaubt — der
Caller soll erst exportieren und dann importieren.
@@ -580,23 +603,31 @@ def memory_import_bootstrap(body: BootstrapBundle):
s = store()
e = embedder()
# Alle aktuell pinned Punkte loeschen
from qdrant_client.http import models as qm
from memory.vector_store import COLLECTION
# Scope bestimmen: explizit aus dem Bundle, sonst aus den memories ableiten.
bundle_scope = body.scope
if bundle_scope not in ("system", "personal", "all"):
scopes_in_mems = {m.get("scope", "personal") for m in body.memories}
bundle_scope = scopes_in_mems.pop() if len(scopes_in_mems) == 1 else "all"
# Nur die pinned Punkte des betroffenen scope loeschen.
del_must = [qm.FieldCondition(key="pinned", match=qm.MatchValue(value=True))]
if bundle_scope in ("system", "personal"):
del_must.append(qm.FieldCondition(key="scope", match=qm.MatchValue(value=bundle_scope)))
s.client.delete(
collection_name=COLLECTION,
points_selector=qm.FilterSelector(filter=qm.Filter(must=[
qm.FieldCondition(key="pinned", match=qm.MatchValue(value=True))
])),
points_selector=qm.FilterSelector(filter=qm.Filter(must=del_must)),
)
# Neue Punkte einspeisen
# Neue Punkte einspeisen — scope pro memory (Fallback: bundle_scope bzw. personal).
created = 0
for m in body.memories:
content = (m.get("content") or "").strip()
if not content:
continue
mscope = m.get("scope") or (bundle_scope if bundle_scope != "all" else "personal")
point = MemoryPoint(
id="",
type=m.get("type", "fact"),
@@ -605,13 +636,14 @@ def memory_import_bootstrap(body: BootstrapBundle):
pinned=True,
category=m.get("category", ""),
source=m.get("source", "bootstrap-import"),
scope=mscope,
tags=list(m.get("tags", [])),
)
vec = e.embed(content)
s.upsert(point, vec)
created += 1
return {"created": created, "deleted_previous_pinned": True}
return {"created": created, "scope": bundle_scope, "deleted_previous_pinned": True}
# ─── Conversation-Loop ──────────────────────────────────────────────
+52 -4
View File
@@ -11,6 +11,10 @@ Punkt-Schema (Payload):
content — eigentlicher Text (wird embedded)
pinned — bool, True = Hot Memory (immer in Prompt)
source — import | conversation | manual
scope — system | personal. system = generische Regeln, die JEDER
braucht, der das System aufsetzt (Sicherheit, Ehrlichkeit,
Skill-Regeln). personal = Stefan-spezifisch (Name, Zugangs-
daten, Projekte). Steuert den getrennten Bootstrap-Export.
tags — Liste von Strings
created_at, updated_at — ISO-Strings
conversation_id — optional, nur fuer type=conversation
@@ -55,6 +59,7 @@ class MemoryPoint:
pinned: bool = False
category: str = ""
source: str = "manual"
scope: str = "personal" # system | personal — steuert Bootstrap-Export
tags: List[str] = field(default_factory=list)
created_at: str = ""
updated_at: str = ""
@@ -74,6 +79,7 @@ class MemoryPoint:
"pinned": self.pinned,
"category": self.category,
"source": self.source,
"scope": self.scope,
"tags": self.tags,
"created_at": self.created_at,
"updated_at": self.updated_at,
@@ -94,6 +100,7 @@ class MemoryPoint:
pinned=payload.get("pinned", False),
category=payload.get("category", ""),
source=payload.get("source", "manual"),
scope=payload.get("scope", "personal"),
tags=payload.get("tags", []),
created_at=payload.get("created_at", ""),
updated_at=payload.get("updated_at", ""),
@@ -120,14 +127,23 @@ class VectorStore:
collection_name=COLLECTION,
vectors_config=qm.VectorParams(size=VECTOR_DIM, distance=qm.Distance.COSINE),
)
# Indexe fuer typische Filter-Felder
for field_name in ("type", "pinned", "category", "source", "migration_key"):
# Indexe fuer typische Filter-Felder — idempotent, laeuft auch auf
# einer bestehenden Collection (fuer neu hinzugekommene Felder wie scope).
self._ensure_indexes()
def _ensure_indexes(self):
for field_name in ("type", "pinned", "category", "source", "scope", "migration_key"):
schema = (qm.PayloadSchemaType.BOOL if field_name == "pinned"
else qm.PayloadSchemaType.KEYWORD)
try:
self.client.create_payload_index(
collection_name=COLLECTION,
field_name=field_name,
field_schema=qm.PayloadSchemaType.KEYWORD if field_name != "pinned"
else qm.PayloadSchemaType.BOOL,
field_schema=schema,
)
except Exception:
# Index existiert bereits — kein Problem.
pass
# ─── Schreib-Operationen ─────────────────────────────────────────
@@ -164,6 +180,38 @@ class VectorStore:
qm.FieldCondition(key="pinned", match=qm.MatchValue(value=True))
]))
def list_pinned_by_scope(self, scope: str) -> List[MemoryPoint]:
"""Alle pinned Punkte eines scope (system | personal). Fuer den
getrennten Bootstrap-Export."""
return self._scroll(filter=qm.Filter(must=[
qm.FieldCondition(key="pinned", match=qm.MatchValue(value=True)),
qm.FieldCondition(key="scope", match=qm.MatchValue(value=scope)),
]))
def list_index_titles(self, limit: int = 500) -> List[MemoryPoint]:
"""Leichtgewichtiger Titel-Index des kalten Gedaechtnisses fuer den
System-Prompt: ARIA sieht WAS sie an Nachschlage-Wissen hat (Zugangs-
daten, Infrastruktur, Projekte) und holt den Inhalt bei Bedarf via
memory_search — statt Stefan nach etwas zu fragen, das schon da ist.
Bewusst NUR die deliberat gespeicherten Punkte:
- nicht pinned (die sind eh schon voll im Prompt),
- kein type=conversation (Chat-Mitschnitte),
- kein source=distilled (die 100e auto-destillierten Gespraechs-
Fakten — die traegt das semantische Auto-Retrieval, sie hier
als Titel zu listen wuerde nur Kontext fressen).
So bleibt der Index klein (Dutzende statt Hunderte Zeilen)."""
return self._scroll(
filter=qm.Filter(
must_not=[
qm.FieldCondition(key="pinned", match=qm.MatchValue(value=True)),
qm.FieldCondition(key="type", match=qm.MatchValue(value="conversation")),
qm.FieldCondition(key="source", match=qm.MatchValue(value="distilled")),
]
),
limit=limit,
)
def list_by_type(self, type_: str, limit: int = 100) -> List[MemoryPoint]:
return self._scroll(
filter=qm.Filter(must=[
+7
View File
@@ -252,6 +252,7 @@ def _parse_user_md(md: str, source_file: str) -> List[MemoryPoint]:
type_="preference", title=f"User: {btitle}",
content=btext, category="allgemein",
migration_key=f"{source_file}/general-{idx}",
scope="personal",
))
else:
cat_key = re.sub(r"[^a-z0-9]+", "-", title.lower()).strip("-") or "allgemein"
@@ -259,6 +260,7 @@ def _parse_user_md(md: str, source_file: str) -> List[MemoryPoint]:
type_="preference", title=title,
content=content, category=cat_key,
migration_key=f"{source_file}/{cat_key}",
scope="personal",
))
return points
@@ -283,7 +285,11 @@ def _mk(
migration_key: str,
pinned: bool = True,
category: str = "",
scope: str = "system",
) -> MemoryPoint:
# scope-Default 'system': AGENT.md + TOOLING.md beschreiben ARIA selbst
# (Identitaet, Sicherheit, Architektur) — das braucht jedes System.
# USER.md-Praeferenzen sind personal und uebergeben scope='personal'.
p = MemoryPoint(
id="",
type=type_,
@@ -292,6 +298,7 @@ def _mk(
pinned=pinned,
category=category,
source="import",
scope=scope,
tags=[],
)
# migration_key wird ueber Payload-Index angesprochen — in to_payload manuell anhaengen
+34
View File
@@ -300,6 +300,36 @@ def build_cold_memory_section(matches: List[MemoryPoint]) -> str:
return "\n".join(lines)
def build_memory_index_section(index_titles: List[MemoryPoint]) -> str:
"""Titel-Index des kalten Gedaechtnisses: ARIA sieht WELCHES Nachschlage-
Wissen sie hat (nur Titel, kein Inhalt = billig), damit sie den Inhalt via
memory_search holt statt Stefan nach etwas zu fragen, das schon da ist.
Nach Kategorie gruppiert; Conversation-Logs + auto-destillierte Fakten sind
bereits ausgefiltert (siehe list_index_titles)."""
if not index_titles:
return ""
grouped: dict[str, List[MemoryPoint]] = {}
for p in index_titles:
key = (p.category or p.type or "sonstiges").strip() or "sonstiges"
grouped.setdefault(key, []).append(p)
lines = [
"## Was in deinem Gedaechtnis liegt (per memory_search abrufbar)",
"Diese Eintraege hast DU gespeichert — hier nur die Titel, nicht der "
"Inhalt. Wenn einer zur Aufgabe passt, hol den Inhalt mit `memory_search` "
"(Titel oder Stichwort). **Frag Stefan NICHT nach etwas, das hier steht** "
"(Zugangsdaten, Server/Hosts, Projekt-Stand, Konfig) — erst nachsehen.",
"",
]
for cat in sorted(grouped.keys()):
items = grouped[cat]
lines.append(f"### {cat}")
for p in items:
lines.append(f"- {p.title}")
lines.append("")
return "\n".join(lines).strip()
def build_skills_section(skills: List[dict]) -> str:
"""Listet alle Skills (aktiv + deaktiviert) damit ARIA weiss was es gibt
und keine doppelt baut. Plus klare Schwelle wann ein Skill sich lohnt."""
@@ -490,6 +520,7 @@ def build_flux_section(flux_config: dict) -> str:
def build_system_prompt(
pinned: List[MemoryPoint],
cold: List[MemoryPoint] | None = None,
memory_index: List[MemoryPoint] | None = None,
skills: List[dict] | None = None,
triggers: List[dict] | None = None,
condition_vars: List[dict] | None = None,
@@ -523,6 +554,9 @@ def build_system_prompt(
callback_host=oauth_callback_host,
callback_port=oauth_callback_port,
callback_tls=oauth_callback_tls))
if memory_index:
parts.append("")
parts.append(build_memory_index_section(memory_index))
if cold:
parts.append("")
parts.append(build_cold_memory_section(cold))
+1
View File
@@ -915,6 +915,7 @@ def apply(store: VectorStore, embedder: Embedder) -> dict:
"pinned": True,
"category": rule.get("category", ""),
"source": "seed",
"scope": "system",
"tags": [],
"created_at": now,
"updated_at": now,
+34 -12
View File
@@ -1080,11 +1080,13 @@
<div style="background:#0D0D1A;border-radius:6px;padding:10px 12px;margin-bottom:8px;">
<div style="color:#FFD60A;font-weight:bold;font-size:12px;margin-bottom:4px;">2. Bootstrap-Snapshot (nur pinned)</div>
<div style="color:#8888AA;font-size:11px;margin-bottom:8px;">
Klein und schnell: <strong>nur</strong> die pinned Memories (Identität, Regeln, Präferenzen, Tools, Skills) als JSON.
Use-Case: Wipe → Bootstrap-Import → ARIA hat Persönlichkeit zurück, sonst leer.
Cold Memory (Konversations-Fakten) bleibt beim Import unangetastet.
Getrennt nach <strong>scope</strong>: <span style="color:#3FFF3F;">System</span> = generische Regeln, die jeder braucht (Sicherheit, Ehrlichkeit, Skill-Regeln) — teilbar für ein frisches System.
<span style="color:#FF9F0A;">Persönlich</span> = Stefan-spezifisch (Name, Zugangsdaten, Projekte) — bleibt privat.
Import ersetzt nur die pinned Memories des jeweiligen scope; Cold Memory bleibt unangetastet.
</div>
<button class="btn secondary" onclick="exportBootstrap()" style="color:#FFD60A;border-color:#FFD60A;">Bootstrap exportieren (JSON)</button>
<button class="btn secondary" onclick="exportBootstrap('system')" style="color:#3FFF3F;border-color:#3FFF3F;">System-Regeln exportieren</button>
<button class="btn secondary" onclick="exportBootstrap('personal')" style="color:#FF9F0A;border-color:#FF9F0A;">⬇ Persönliches exportieren</button>
<button class="btn secondary" onclick="exportBootstrap('all')" style="color:#FFD60A;border-color:#FFD60A;">⬇ Alles (Vollbackup)</button>
<input type="file" id="bootstrap-import-file" accept=".json,application/json" style="display:none" onchange="importBootstrap(event)">
<button class="btn secondary" onclick="document.getElementById('bootstrap-import-file').click()" style="color:#FFD60A;border-color:#FFD60A;">⬆ Bootstrap importieren</button>
<div id="bootstrap-status" style="margin-top:8px;font-size:11px;color:#8888AA;"></div>
@@ -1408,6 +1410,11 @@
<input type="checkbox" id="memory-pinned">
<span>📌 Pinned (Hot Memory — IMMER im System-Prompt)</span>
</label>
<label style="display:block;color:#8888AA;font-size:11px;margin-top:10px;margin-bottom:3px;">Scope (steuert Bootstrap-Export):</label>
<select id="memory-scope" style="width:100%;background:#0D0D1A;color:#E0E0F0;border:1px solid #1E1E2E;padding:6px;border-radius:4px;font-family:inherit;margin-bottom:10px;">
<option value="personal">🟠 Persönlich — Stefan-spezifisch, bleibt privat</option>
<option value="system">🟢 System — generische Regel, teilbar für frisches System</option>
</select>
<!-- 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;">
@@ -5804,9 +5811,15 @@
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>` : '';
// scope-Badge nur bei pinned (nur die werden exportiert — da zaehlt die Trennung).
const scopeBadge = m.pinned
? (m.scope === 'system'
? `<span style="color:#3FFF3F;font-size:9px;margin-left:6px;border:1px solid #3FFF3F;border-radius:3px;padding:0 3px;" title="System-Regel — kommt in den System-Export">SYS</span>`
: `<span style="color:#FF9F0A;font-size:9px;margin-left:6px;border:1px solid #FF9F0A;border-radius:3px;padding:0 3px;" title="Persönlich — bleibt privat">PRIV</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}${attBadge}
<div style="color:#E0E0F0;font-size:12px;">${typeBadge}${pin}<strong>${escapeHtml(m.title || '(ohne Titel)')}</strong>${score}${attBadge}${scopeBadge}
${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>
@@ -6016,6 +6029,7 @@
document.getElementById('memory-category').value = m.category || '';
document.getElementById('memory-tags').value = (m.tags || []).join(', ');
document.getElementById('memory-pinned').checked = !!m.pinned;
document.getElementById('memory-scope').value = (m.scope === 'system') ? 'system' : 'personal';
// Anhang-Block sichtbar — Liste rendern
if (attBlock) attBlock.style.display = 'block';
if (attHint) attHint.style.display = 'none';
@@ -6029,6 +6043,7 @@
document.getElementById('memory-category').value = '';
document.getElementById('memory-tags').value = '';
document.getElementById('memory-pinned').checked = false;
document.getElementById('memory-scope').value = 'personal';
// Bei neuem Memory: nur Hinweis, dass Anhaenge nach Save gehen
if (attBlock) attBlock.style.display = 'none';
if (attHint) attHint.style.display = 'block';
@@ -6133,6 +6148,7 @@
const category = document.getElementById('memory-category').value.trim();
const tags = document.getElementById('memory-tags').value.split(',').map(t => t.trim()).filter(Boolean);
const pinned = document.getElementById('memory-pinned').checked;
const scope = document.getElementById('memory-scope').value || 'personal';
if (!title) { errEl.textContent = 'Titel fehlt.'; errEl.style.display = 'block'; return; }
if (!content) { errEl.textContent = 'Inhalt fehlt.'; errEl.style.display = 'block'; return; }
@@ -6143,13 +6159,13 @@
r = await fetch('/api/brain/memory/update/' + encodeURIComponent(id), {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title, content, pinned, category, tags }),
body: JSON.stringify({ title, content, pinned, category, scope, tags }),
});
} else {
r = await fetch('/api/brain/memory/save', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type, title, content, pinned, category, tags, source: 'manual' }),
body: JSON.stringify({ type, title, content, pinned, category, scope, tags, source: 'manual' }),
});
}
if (!r.ok) {
@@ -6526,11 +6542,12 @@
}
// ── Bootstrap Export / Import ──────────────────────────
async function exportBootstrap() {
async function exportBootstrap(scope) {
scope = scope || 'system';
const status = document.getElementById('bootstrap-status');
if (status) status.innerHTML = '⏳ Lade...';
try {
const r = await fetch('/api/brain/memory/export-bootstrap');
const r = await fetch('/api/brain/memory/export-bootstrap?scope=' + encodeURIComponent(scope));
if (!r.ok) throw new Error('HTTP ' + r.status);
const data = await r.json();
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
@@ -6538,10 +6555,11 @@
const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
const a = document.createElement('a');
a.href = url;
a.download = `aria-bootstrap-${ts}.json`;
a.download = `aria-bootstrap-${scope}-${ts}.json`;
document.body.appendChild(a); a.click();
setTimeout(() => { URL.revokeObjectURL(url); a.remove(); }, 100);
if (status) status.innerHTML = `<span style="color:#3FFF3F;">✓ ${data.count} pinned Memories exportiert</span>`;
const label = scope === 'system' ? 'System-Regeln' : (scope === 'personal' ? 'persönliche Memories' : 'pinned Memories');
if (status) status.innerHTML = `<span style="color:#3FFF3F;">✓ ${data.count} ${label} exportiert</span>`;
} catch (e) {
if (status) status.innerHTML = `<span style="color:#FF6B6B;">✗ ${e.message}</span>`;
}
@@ -6555,7 +6573,11 @@
const text = await file.text();
const bundle = JSON.parse(text);
if (!Array.isArray(bundle.memories)) throw new Error('Datei hat kein "memories"-Array');
if (!confirm(`Bootstrap importieren?\n\n${bundle.memories.length} pinned Memories aus "${file.name}".\n\nALLE aktuell pinned Memories werden überschrieben. Cold Memory bleibt unverändert.`)) {
const bScope = bundle.scope || 'all';
const scopeInfo = bScope === 'system' ? 'Nur die aktuell pinned SYSTEM-Regeln werden ersetzt — Persönliches bleibt.'
: bScope === 'personal' ? 'Nur die aktuell pinned PERSÖNLICHEN Memories werden ersetzt — System-Regeln bleiben.'
: 'ALLE aktuell pinned Memories werden überschrieben.';
if (!confirm(`Bootstrap importieren? (scope: ${bScope})\n\n${bundle.memories.length} pinned Memories aus "${file.name}".\n\n${scopeInfo} Cold Memory bleibt unverändert.`)) {
event.target.value = '';
return;
}
+45
View File
@@ -29,6 +29,7 @@ import base64
import json
import logging
import os
import re
import tempfile
import time
from dataclasses import dataclass, field
@@ -74,6 +75,25 @@ STREAM_VOICE_RMS_MAX = 0.020
# (Voxtral halluziniert aus Fast-Nichts sonst einen Fuellsatz). 2 ≈ 400ms.
STREAM_MIN_VOICED_FRAMES = int(os.getenv("STREAM_MIN_VOICED_FRAMES", "2"))
# Halluzinations-Filter (2. Netz NACH der Transkription). Der voiced_frames-Guard
# oben faengt die reine Stille; hier kommt das "borderline"-Band dazu: wenn wenig
# echte Stimme da war UND das Transkript ein bekanntes Voxtral-Silence-Artefakt
# ist (Untertitel-Credits, Staedte-/Geo-Fakten "Flaeche von X km2"), ist es fast
# sicher ein Phantom aus Fast-Nichts → verwerfen. Gegated auf wenig voiced_frames,
# damit eine ECHTE Geografie-Frage (die hat normale Stimm-Energie) durchgeht.
STREAM_HALLUC_GUARD_FRAMES = int(os.getenv("STREAM_HALLUC_GUARD_FRAMES",
str(STREAM_MIN_VOICED_FRAMES * 4))) # ~1.6s
_HALLUCINATION_RE = re.compile(
r"untertitel"
r"|amara\.org"
r"|vielen\s+dank\s+f[uü]r'?s?\s+(zuschauen|zusehen|zuh[oö]ren)"
r"|bis\s+zum\s+n[aä]chsten\s+mal"
r"|abonnier"
r"|fl[aä]che\s+von\s+[\d.,]+\s*(km|quadratkilometer)"
r"|[\d.,]+\s*(km²|quadratkilometern?|einwohnern?)\b",
re.IGNORECASE,
)
# Speaker-ID Gating global an/aus. DEFAULT AUS (fail-open) — die "nur meine Stimme"-
# Pruefung ist ein BEWUSSTER Schalter, kein Automatismus: ein einziger schlechter
# Enroll darf nie die ganze STT lahmlegen (genau das ist passiert). Wird per config-
@@ -449,6 +469,31 @@ class SessionManager:
duration_s = audio.size / 16000.0
logger.info("Stream %s: FINAL (reason=%s, %.1fs, %dms): %r",
sess.request_id[:8], reason, duration_s, stt_ms, final_text[:120])
# Halluzinations-Filter (2. Netz): leeres/Artefakt-Transkript im borderline-
# Band → als no-speech verwerfen statt ein Phantom ("Die Stadt hat eine
# Flaeche von 1,5 km2") ans Brain zu schicken. Manueller Stop (stream_end)
# ist ausgenommen (bewusst gesprochen, auch kurze Woerter zaehlen).
_clean = final_text.strip(" .,!?…-\t\n\r")
_borderline = sess.voiced_frames < STREAM_HALLUC_GUARD_FRAMES
_is_phantom = (not _clean) or (_borderline and bool(_HALLUCINATION_RE.search(final_text)))
if reason != "stream_end" and _is_phantom:
logger.info("Stream %s: Halluzination verworfen (voiced_frames=%d<%d, %.1fs, text=%r)",
sess.request_id[:8], sess.voiced_frames, STREAM_HALLUC_GUARD_FRAMES,
duration_s, final_text[:80])
if self._ws is not None:
nospeech = {"requestId": sess.request_id,
"audioRequestId": sess.audio_request_id,
"text": "", "reason": f"hallucination:{reason}",
"durationS": 0.0, "sttMs": stt_ms}
await _send(self._ws, "stt_endpoint", nospeech)
await _send(self._ws, "stt_stream_done", {
"requestId": sess.request_id,
"audioRequestId": sess.audio_request_id,
"text": "", "reason": f"hallucination:{reason}"})
self.drop(sess.request_id)
return
if self._ws is not None:
payload = {
"requestId": sess.request_id,