Compare commits

...
5 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
13 changed files with 390 additions and 40 deletions
+2 -2
View File
@@ -79,8 +79,8 @@ android {
applicationId "com.ariacockpit" applicationId "com.ariacockpit"
minSdkVersion rootProject.ext.minSdkVersion minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 20401 versionCode 20402
versionName "0.2.4.1" versionName "0.2.4.2"
// Fallback fuer Libraries mit Product Flavors // Fallback fuer Libraries mit Product Flavors
missingDimensionStrategy 'react-native-camera', 'general' missingDimensionStrategy 'react-native-camera', 'general'
} }
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "aria-cockpit", "name": "aria-cockpit",
"version": "0.2.4.1", "version": "0.2.4.2",
"private": true, "private": true,
"scripts": { "scripts": {
"android": "react-native run-android", "android": "react-native run-android",
+27
View File
@@ -112,6 +112,8 @@ import wakeWordService, {
WAKE_THRESHOLD_MAX, WAKE_THRESHOLD_MAX,
loadWakeThreshold, loadWakeThreshold,
saveWakeThreshold, saveWakeThreshold,
loadBgWakeEnabled,
saveBgWakeEnabled,
} from '../services/wakeword'; } from '../services/wakeword';
import ModeSelector from '../components/ModeSelector'; import ModeSelector from '../components/ModeSelector';
import QRScanner from '../components/QRScanner'; import QRScanner from '../components/QRScanner';
@@ -212,6 +214,8 @@ const SettingsScreen: React.FC = () => {
const [wakeStatus, setWakeStatus] = useState<string>(''); const [wakeStatus, setWakeStatus] = useState<string>('');
const [wakeReadySound, setWakeReadySound] = useState<boolean>(true); const [wakeReadySound, setWakeReadySound] = useState<boolean>(true);
const [wakeThreshold, setWakeThreshold] = useState<number>(WAKE_THRESHOLD_DEFAULT); const [wakeThreshold, setWakeThreshold] = useState<number>(WAKE_THRESHOLD_DEFAULT);
// 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 [editingPath, setEditingPath] = useState(false);
const [xttsVoice, setXttsVoice] = useState(''); const [xttsVoice, setXttsVoice] = useState('');
const [loadingVoice, setLoadingVoice] = useState<string | null>(null); const [loadingVoice, setLoadingVoice] = useState<string | null>(null);
@@ -336,6 +340,7 @@ const SettingsScreen: React.FC = () => {
}); });
isWakeReadySoundEnabled().then(setWakeReadySound); isWakeReadySoundEnabled().then(setWakeReadySound);
loadWakeThreshold().then(setWakeThreshold).catch(() => {}); loadWakeThreshold().then(setWakeThreshold).catch(() => {});
loadBgWakeEnabled().then(setBgWake).catch(() => {});
updateService.getApkCacheSize().then(setApkCacheInfo).catch(() => {}); updateService.getApkCacheSize().then(setApkCacheInfo).catch(() => {});
audioService.getTtsCacheSize().then(setTtsCacheInfo).catch(() => {}); audioService.getTtsCacheSize().then(setTtsCacheInfo).catch(() => {});
AsyncStorage.getItem('aria_xtts_voice').then(saved => { AsyncStorage.getItem('aria_xtts_voice').then(saved => {
@@ -1894,6 +1899,28 @@ const SettingsScreen: React.FC = () => {
/> />
</View> </View>
<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);
}}
trackColor={{ false: '#2A2A3E', true: '#0096FF' }}
thumbColor={bgWake ? '#FFFFFF' : '#666680'}
/>
</View>
<Text style={[styles.toggleLabel, {marginTop: 20}]}>Weiterreden nach der Antwort</Text> <Text style={[styles.toggleLabel, {marginTop: 20}]}>Weiterreden nach der Antwort</Text>
<Text style={styles.toggleHint}> <Text style={styles.toggleHint}>
Nach einer gesprochenen ARIA-Antwort geht das Mikro auf du kannst ohne Nach einer gesprochenen ARIA-Antwort geht das Mikro auf du kannst ohne
+39 -5
View File
@@ -65,6 +65,28 @@ export async function saveWakeThreshold(v: number): Promise<void> {
await AsyncStorage.setItem(WAKE_THRESHOLD_STORAGE_KEY, String(v)); 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 /** Verfuegbare Wake-Words — entsprechen den .onnx Dateien in
* android/app/src/main/assets/openwakeword/. Custom-Keywords (eigenes * android/app/src/main/assets/openwakeword/. Custom-Keywords (eigenes
* Training via openwakeword Notebook) muessen aktuell als Asset eingebaut * Training via openwakeword Notebook) muessen aktuell als Asset eingebaut
@@ -133,6 +155,10 @@ class WakeWordService {
* Hintergrund-Detections sind quasi immer false-positives (TV, Husten, * Hintergrund-Detections sind quasi immer false-positives (TV, Husten,
* AudioFocus-Switch beim Wechsel zu Musik etc.). */ * AudioFocus-Switch beim Wechsel zu Musik etc.). */
private inBackground: boolean = false; 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 /** Re-Entry-Guard fuer onWakeDetected: native kann mehrere
* WakeWordDetected-Events emitten BEVOR OpenWakeWord.stop() in JS * WakeWordDetected-Events emitten BEVOR OpenWakeWord.stop() in JS
* resolved (Bridge-Queue + Doze-Backlog). Mit dem Flag wird das zweite * resolved (Bridge-Queue + Doze-Backlog). Mit dem Flag wird das zweite
@@ -214,7 +240,8 @@ class WakeWordService {
this.initInProgress = (async () => { this.initInProgress = (async () => {
try { try {
const threshold = await loadWakeThreshold(); 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); await OpenWakeWord.init(this.keyword, threshold, DEFAULT_PATIENCE, DEFAULT_DEBOUNCE_MS);
// Subscribe nur einmal // Subscribe nur einmal
if (!this.eventSub) { if (!this.eventSub) {
@@ -311,7 +338,14 @@ class WakeWordService {
* was als „Wake-Word" reinkommt ist Husten/TV/AudioFocus-Switch. */ * was als „Wake-Word" reinkommt ist Husten/TV/AudioFocus-Switch. */
setBackground(): void { setBackground(): void {
this.inBackground = true; 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 /** App im Vordergrund: Detections wieder freigeben, plus kurzer Cooldown
@@ -326,9 +360,9 @@ class WakeWordService {
/** Wake-Word getriggert: Native-Modul pausieren, Konversation starten. */ /** Wake-Word getriggert: Native-Modul pausieren, Konversation starten. */
private async onWakeDetected(): Promise<void> { private async onWakeDetected(): Promise<void> {
if (this.inBackground) { if (this.inBackground && !this.bgWakeEnabled) {
console.log('[WakeWord] Trigger ignoriert (App im Hintergrund)'); console.log('[WakeWord] Trigger ignoriert (App im Hintergrund, Hintergrund-Wake aus)');
import('./logger').then(m => m.reportAppDebug('wake.detect', 'ignored: app in background')).catch(()=>{}); import('./logger').then(m => m.reportAppDebug('wake.detect', 'ignored: app in background (bg-wake off)')).catch(()=>{});
return; return;
} }
// Re-Entry-Guard: blocken wenn ein Detection-Zyklus schon laeuft. // Re-Entry-Guard: blocken wenn ein Detection-Zyklus schon laeuft.
+13 -3
View File
@@ -1402,10 +1402,10 @@ def _extract_flow_markers(text: str) -> tuple:
# ([^.!?]{0,15}) in beliebiger Reihenfolge; "befehls?kette" damit "Lieferkette" # ([^.!?]{0,15}) in beliebiger Reihenfolge; "befehls?kette" damit "Lieferkette"
# o.ae. nicht faelschlich matcht. # o.ae. nicht faelschlich matcht.
_CONV_NOUN = r"(?:konversation|gespr[aä]ch|befehls?kette)" _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( _END_CONVERSATION_RE = re.compile(
rf"\b{_CONV_NOUN}\b[^.!?]{{0,15}}\b{_CONV_END_VERB}\b" 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, re.IGNORECASE,
) )
@@ -1929,6 +1929,15 @@ class Agent:
logger.warning("Cold-Search fehlgeschlagen: %s", exc) logger.warning("Cold-Search fehlgeschlagen: %s", exc)
cold = [] 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 # 4. Aktive Skills holen + Tool-Liste bauen
all_skills = skills_mod.list_skills(active_only=False) all_skills = skills_mod.list_skills(active_only=False)
active_skills = [s for s in all_skills if s.get("active", True)] 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_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" 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, triggers=all_triggers,
condition_vars=condition_vars, condition_vars=condition_vars,
condition_funcs=condition_funcs, 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 pinned: bool = False
category: str = "" category: str = ""
source: str = "manual" source: str = "manual"
scope: str = "personal" # system | personal — steuert Bootstrap-Export
tags: List[str] = Field(default_factory=list) tags: List[str] = Field(default_factory=list)
conversation_id: Optional[str] = None conversation_id: Optional[str] = None
# Vorhandene Anhang-Metadaten beim Save mitgeben (i.d.R. werden Anhaenge # Vorhandene Anhang-Metadaten beim Save mitgeben (i.d.R. werden Anhaenge
@@ -203,6 +204,7 @@ class MemoryUpdate(BaseModel):
content: Optional[str] = None content: Optional[str] = None
pinned: Optional[bool] = None pinned: Optional[bool] = None
category: Optional[str] = None category: Optional[str] = None
scope: Optional[str] = None # system | personal
tags: Optional[List[str]] = None tags: Optional[List[str]] = None
@@ -214,6 +216,7 @@ class MemoryOut(BaseModel):
pinned: bool pinned: bool
category: str category: str
source: str source: str
scope: str = "personal"
tags: List[str] tags: List[str]
created_at: str created_at: str
updated_at: str updated_at: str
@@ -328,6 +331,7 @@ def memory_save(body: MemoryIn):
pinned=body.pinned, pinned=body.pinned,
category=body.category, category=body.category,
source=body.source, source=body.source,
scope=body.scope,
tags=body.tags, tags=body.tags,
conversation_id=body.conversation_id, conversation_id=body.conversation_id,
attachments=body.attachments or [], attachments=body.attachments or [],
@@ -353,6 +357,8 @@ def memory_update(point_id: str, body: MemoryUpdate):
existing.pinned = body.pinned existing.pinned = body.pinned
if body.category is not None: if body.category is not None:
existing.category = body.category existing.category = body.category
if body.scope is not None:
existing.scope = body.scope
if body.tags is not None: if body.tags is not None:
existing.tags = body.tags existing.tags = body.tags
@@ -537,12 +543,23 @@ def memory_import_files():
# Wiederherstellen einer schlanken ARIA nach Wipe. # Wiederherstellen einer schlanken ARIA nach Wipe.
@app.get("/memory/export-bootstrap") @app.get("/memory/export-bootstrap")
def memory_export_bootstrap(): def memory_export_bootstrap(scope: str = "system"):
"""Gibt alle pinned Memories als JSON zurueck — fuer Browser-Download.""" """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() 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 { return {
"version": 1, "version": 2,
"scope": scope,
"exported_at": __import__("datetime").datetime.now( "exported_at": __import__("datetime").datetime.now(
__import__("datetime").timezone.utc __import__("datetime").timezone.utc
).isoformat(), ).isoformat(),
@@ -555,6 +572,7 @@ def memory_export_bootstrap():
"pinned": True, "pinned": True,
"category": p.category, "category": p.category,
"source": p.source, "source": p.source,
"scope": p.scope,
"tags": p.tags, "tags": p.tags,
} }
for p in pinned for p in pinned
@@ -564,13 +582,18 @@ def memory_export_bootstrap():
class BootstrapBundle(BaseModel): class BootstrapBundle(BaseModel):
version: int = 1 version: int = 1
scope: Optional[str] = None # system | personal | all (aus dem Export)
memories: List[dict] memories: List[dict]
@app.post("/memory/import-bootstrap") @app.post("/memory/import-bootstrap")
def memory_import_bootstrap(body: BootstrapBundle): def memory_import_bootstrap(body: BootstrapBundle):
"""Loescht alle pinned Memories und importiert die im Bundle. """Importiert ein Bootstrap-Bundle scope-sicher.
Cold Memory (unpinned) bleibt unangetastet.
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 Wenn keine Memories im Bundle: nur loeschen ist NICHT erlaubt — der
Caller soll erst exportieren und dann importieren. Caller soll erst exportieren und dann importieren.
@@ -580,23 +603,31 @@ def memory_import_bootstrap(body: BootstrapBundle):
s = store() s = store()
e = embedder() e = embedder()
# Alle aktuell pinned Punkte loeschen
from qdrant_client.http import models as qm from qdrant_client.http import models as qm
from memory.vector_store import COLLECTION 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( s.client.delete(
collection_name=COLLECTION, collection_name=COLLECTION,
points_selector=qm.FilterSelector(filter=qm.Filter(must=[ points_selector=qm.FilterSelector(filter=qm.Filter(must=del_must)),
qm.FieldCondition(key="pinned", match=qm.MatchValue(value=True))
])),
) )
# Neue Punkte einspeisen # Neue Punkte einspeisen — scope pro memory (Fallback: bundle_scope bzw. personal).
created = 0 created = 0
for m in body.memories: for m in body.memories:
content = (m.get("content") or "").strip() content = (m.get("content") or "").strip()
if not content: if not content:
continue continue
mscope = m.get("scope") or (bundle_scope if bundle_scope != "all" else "personal")
point = MemoryPoint( point = MemoryPoint(
id="", id="",
type=m.get("type", "fact"), type=m.get("type", "fact"),
@@ -605,13 +636,14 @@ def memory_import_bootstrap(body: BootstrapBundle):
pinned=True, pinned=True,
category=m.get("category", ""), category=m.get("category", ""),
source=m.get("source", "bootstrap-import"), source=m.get("source", "bootstrap-import"),
scope=mscope,
tags=list(m.get("tags", [])), tags=list(m.get("tags", [])),
) )
vec = e.embed(content) vec = e.embed(content)
s.upsert(point, vec) s.upsert(point, vec)
created += 1 created += 1
return {"created": created, "deleted_previous_pinned": True} return {"created": created, "scope": bundle_scope, "deleted_previous_pinned": True}
# ─── Conversation-Loop ────────────────────────────────────────────── # ─── Conversation-Loop ──────────────────────────────────────────────
+52 -4
View File
@@ -11,6 +11,10 @@ Punkt-Schema (Payload):
content — eigentlicher Text (wird embedded) content — eigentlicher Text (wird embedded)
pinned — bool, True = Hot Memory (immer in Prompt) pinned — bool, True = Hot Memory (immer in Prompt)
source — import | conversation | manual 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 tags — Liste von Strings
created_at, updated_at — ISO-Strings created_at, updated_at — ISO-Strings
conversation_id — optional, nur fuer type=conversation conversation_id — optional, nur fuer type=conversation
@@ -55,6 +59,7 @@ class MemoryPoint:
pinned: bool = False pinned: bool = False
category: str = "" category: str = ""
source: str = "manual" source: str = "manual"
scope: str = "personal" # system | personal — steuert Bootstrap-Export
tags: List[str] = field(default_factory=list) tags: List[str] = field(default_factory=list)
created_at: str = "" created_at: str = ""
updated_at: str = "" updated_at: str = ""
@@ -74,6 +79,7 @@ class MemoryPoint:
"pinned": self.pinned, "pinned": self.pinned,
"category": self.category, "category": self.category,
"source": self.source, "source": self.source,
"scope": self.scope,
"tags": self.tags, "tags": self.tags,
"created_at": self.created_at, "created_at": self.created_at,
"updated_at": self.updated_at, "updated_at": self.updated_at,
@@ -94,6 +100,7 @@ class MemoryPoint:
pinned=payload.get("pinned", False), pinned=payload.get("pinned", False),
category=payload.get("category", ""), category=payload.get("category", ""),
source=payload.get("source", "manual"), source=payload.get("source", "manual"),
scope=payload.get("scope", "personal"),
tags=payload.get("tags", []), tags=payload.get("tags", []),
created_at=payload.get("created_at", ""), created_at=payload.get("created_at", ""),
updated_at=payload.get("updated_at", ""), updated_at=payload.get("updated_at", ""),
@@ -120,14 +127,23 @@ class VectorStore:
collection_name=COLLECTION, collection_name=COLLECTION,
vectors_config=qm.VectorParams(size=VECTOR_DIM, distance=qm.Distance.COSINE), vectors_config=qm.VectorParams(size=VECTOR_DIM, distance=qm.Distance.COSINE),
) )
# Indexe fuer typische Filter-Felder # Indexe fuer typische Filter-Felder — idempotent, laeuft auch auf
for field_name in ("type", "pinned", "category", "source", "migration_key"): # 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( self.client.create_payload_index(
collection_name=COLLECTION, collection_name=COLLECTION,
field_name=field_name, field_name=field_name,
field_schema=qm.PayloadSchemaType.KEYWORD if field_name != "pinned" field_schema=schema,
else qm.PayloadSchemaType.BOOL,
) )
except Exception:
# Index existiert bereits — kein Problem.
pass
# ─── Schreib-Operationen ───────────────────────────────────────── # ─── Schreib-Operationen ─────────────────────────────────────────
@@ -164,6 +180,38 @@ class VectorStore:
qm.FieldCondition(key="pinned", match=qm.MatchValue(value=True)) 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]: def list_by_type(self, type_: str, limit: int = 100) -> List[MemoryPoint]:
return self._scroll( return self._scroll(
filter=qm.Filter(must=[ 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}", type_="preference", title=f"User: {btitle}",
content=btext, category="allgemein", content=btext, category="allgemein",
migration_key=f"{source_file}/general-{idx}", migration_key=f"{source_file}/general-{idx}",
scope="personal",
)) ))
else: else:
cat_key = re.sub(r"[^a-z0-9]+", "-", title.lower()).strip("-") or "allgemein" 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, type_="preference", title=title,
content=content, category=cat_key, content=content, category=cat_key,
migration_key=f"{source_file}/{cat_key}", migration_key=f"{source_file}/{cat_key}",
scope="personal",
)) ))
return points return points
@@ -283,7 +285,11 @@ def _mk(
migration_key: str, migration_key: str,
pinned: bool = True, pinned: bool = True,
category: str = "", category: str = "",
scope: str = "system",
) -> MemoryPoint: ) -> 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( p = MemoryPoint(
id="", id="",
type=type_, type=type_,
@@ -292,6 +298,7 @@ def _mk(
pinned=pinned, pinned=pinned,
category=category, category=category,
source="import", source="import",
scope=scope,
tags=[], tags=[],
) )
# migration_key wird ueber Payload-Index angesprochen — in to_payload manuell anhaengen # 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) 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: def build_skills_section(skills: List[dict]) -> str:
"""Listet alle Skills (aktiv + deaktiviert) damit ARIA weiss was es gibt """Listet alle Skills (aktiv + deaktiviert) damit ARIA weiss was es gibt
und keine doppelt baut. Plus klare Schwelle wann ein Skill sich lohnt.""" 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( def build_system_prompt(
pinned: List[MemoryPoint], pinned: List[MemoryPoint],
cold: List[MemoryPoint] | None = None, cold: List[MemoryPoint] | None = None,
memory_index: List[MemoryPoint] | None = None,
skills: List[dict] | None = None, skills: List[dict] | None = None,
triggers: List[dict] | None = None, triggers: List[dict] | None = None,
condition_vars: List[dict] | None = None, condition_vars: List[dict] | None = None,
@@ -523,6 +554,9 @@ def build_system_prompt(
callback_host=oauth_callback_host, callback_host=oauth_callback_host,
callback_port=oauth_callback_port, callback_port=oauth_callback_port,
callback_tls=oauth_callback_tls)) callback_tls=oauth_callback_tls))
if memory_index:
parts.append("")
parts.append(build_memory_index_section(memory_index))
if cold: if cold:
parts.append("") parts.append("")
parts.append(build_cold_memory_section(cold)) parts.append(build_cold_memory_section(cold))
+1
View File
@@ -915,6 +915,7 @@ def apply(store: VectorStore, embedder: Embedder) -> dict:
"pinned": True, "pinned": True,
"category": rule.get("category", ""), "category": rule.get("category", ""),
"source": "seed", "source": "seed",
"scope": "system",
"tags": [], "tags": [],
"created_at": now, "created_at": now,
"updated_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="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:#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;"> <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. 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.
Use-Case: Wipe → Bootstrap-Import → ARIA hat Persönlichkeit zurück, sonst leer. <span style="color:#FF9F0A;">Persönlich</span> = Stefan-spezifisch (Name, Zugangsdaten, Projekte) — bleibt privat.
Cold Memory (Konversations-Fakten) bleibt beim Import unangetastet. Import ersetzt nur die pinned Memories des jeweiligen scope; Cold Memory bleibt unangetastet.
</div> </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)"> <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> <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> <div id="bootstrap-status" style="margin-top:8px;font-size:11px;color:#8888AA;"></div>
@@ -1408,6 +1410,11 @@
<input type="checkbox" id="memory-pinned"> <input type="checkbox" id="memory-pinned">
<span>📌 Pinned (Hot Memory — IMMER im System-Prompt)</span> <span>📌 Pinned (Hot Memory — IMMER im System-Prompt)</span>
</label> </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 --> <!-- 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;"> <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 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 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>` : ''; 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;"> 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="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>` : ''} ${m.category ? `<span style="color:#555570;font-weight:normal;font-size:10px;margin-left:6px;">[${escapeHtml(m.category)}]</span>` : ''}
</div> </div>
<div style="color:#888;font-size:11px;line-height:1.4;">${escapeHtml(preview)}${m.content && m.content.length > 140 ? '...' : ''}</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-category').value = m.category || '';
document.getElementById('memory-tags').value = (m.tags || []).join(', '); document.getElementById('memory-tags').value = (m.tags || []).join(', ');
document.getElementById('memory-pinned').checked = !!m.pinned; document.getElementById('memory-pinned').checked = !!m.pinned;
document.getElementById('memory-scope').value = (m.scope === 'system') ? 'system' : 'personal';
// Anhang-Block sichtbar — Liste rendern // Anhang-Block sichtbar — Liste rendern
if (attBlock) attBlock.style.display = 'block'; if (attBlock) attBlock.style.display = 'block';
if (attHint) attHint.style.display = 'none'; if (attHint) attHint.style.display = 'none';
@@ -6029,6 +6043,7 @@
document.getElementById('memory-category').value = ''; document.getElementById('memory-category').value = '';
document.getElementById('memory-tags').value = ''; document.getElementById('memory-tags').value = '';
document.getElementById('memory-pinned').checked = false; document.getElementById('memory-pinned').checked = false;
document.getElementById('memory-scope').value = 'personal';
// Bei neuem Memory: nur Hinweis, dass Anhaenge nach Save gehen // Bei neuem Memory: nur Hinweis, dass Anhaenge nach Save gehen
if (attBlock) attBlock.style.display = 'none'; if (attBlock) attBlock.style.display = 'none';
if (attHint) attHint.style.display = 'block'; if (attHint) attHint.style.display = 'block';
@@ -6133,6 +6148,7 @@
const category = document.getElementById('memory-category').value.trim(); const category = document.getElementById('memory-category').value.trim();
const tags = document.getElementById('memory-tags').value.split(',').map(t => t.trim()).filter(Boolean); const tags = document.getElementById('memory-tags').value.split(',').map(t => t.trim()).filter(Boolean);
const pinned = document.getElementById('memory-pinned').checked; 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 (!title) { errEl.textContent = 'Titel fehlt.'; errEl.style.display = 'block'; return; }
if (!content) { errEl.textContent = 'Inhalt 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), { r = await fetch('/api/brain/memory/update/' + encodeURIComponent(id), {
method: 'PATCH', method: 'PATCH',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title, content, pinned, category, tags }), body: JSON.stringify({ title, content, pinned, category, scope, tags }),
}); });
} else { } else {
r = await fetch('/api/brain/memory/save', { r = await fetch('/api/brain/memory/save', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, 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) { if (!r.ok) {
@@ -6526,11 +6542,12 @@
} }
// ── Bootstrap Export / Import ────────────────────────── // ── Bootstrap Export / Import ──────────────────────────
async function exportBootstrap() { async function exportBootstrap(scope) {
scope = scope || 'system';
const status = document.getElementById('bootstrap-status'); const status = document.getElementById('bootstrap-status');
if (status) status.innerHTML = '⏳ Lade...'; if (status) status.innerHTML = '⏳ Lade...';
try { 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); if (!r.ok) throw new Error('HTTP ' + r.status);
const data = await r.json(); const data = await r.json();
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/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 ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
const a = document.createElement('a'); const a = document.createElement('a');
a.href = url; a.href = url;
a.download = `aria-bootstrap-${ts}.json`; a.download = `aria-bootstrap-${scope}-${ts}.json`;
document.body.appendChild(a); a.click(); document.body.appendChild(a); a.click();
setTimeout(() => { URL.revokeObjectURL(url); a.remove(); }, 100); 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) { } catch (e) {
if (status) status.innerHTML = `<span style="color:#FF6B6B;">✗ ${e.message}</span>`; if (status) status.innerHTML = `<span style="color:#FF6B6B;">✗ ${e.message}</span>`;
} }
@@ -6555,7 +6573,11 @@
const text = await file.text(); const text = await file.text();
const bundle = JSON.parse(text); const bundle = JSON.parse(text);
if (!Array.isArray(bundle.memories)) throw new Error('Datei hat kein "memories"-Array'); 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 = ''; event.target.value = '';
return; return;
} }
+45
View File
@@ -29,6 +29,7 @@ import base64
import json import json
import logging import logging
import os import os
import re
import tempfile import tempfile
import time import time
from dataclasses import dataclass, field 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. # (Voxtral halluziniert aus Fast-Nichts sonst einen Fuellsatz). 2 ≈ 400ms.
STREAM_MIN_VOICED_FRAMES = int(os.getenv("STREAM_MIN_VOICED_FRAMES", "2")) 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"- # Speaker-ID Gating global an/aus. DEFAULT AUS (fail-open) — die "nur meine Stimme"-
# Pruefung ist ein BEWUSSTER Schalter, kein Automatismus: ein einziger schlechter # Pruefung ist ein BEWUSSTER Schalter, kein Automatismus: ein einziger schlechter
# Enroll darf nie die ganze STT lahmlegen (genau das ist passiert). Wird per config- # 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 duration_s = audio.size / 16000.0
logger.info("Stream %s: FINAL (reason=%s, %.1fs, %dms): %r", logger.info("Stream %s: FINAL (reason=%s, %.1fs, %dms): %r",
sess.request_id[:8], reason, duration_s, stt_ms, final_text[:120]) 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: if self._ws is not None:
payload = { payload = {
"requestId": sess.request_id, "requestId": sess.request_id,