From b4bba018e8cac40c0a688b0b5c9862ec933ca13f Mon Sep 17 00:00:00 2001 From: MathiasEngel Date: Fri, 31 Jul 2026 21:37:39 +0200 Subject: [PATCH] Protect private prompts and stabilize Eve barge-in --- README.md | 2 +- RELEASES.md | 1 + core/prompt_files.py | 53 +++++++++++++++++++ core/settings_ui.py | 19 +++++-- core/trinity_bridge.py | 19 ++++--- core/voice/local_realtime_client.py | 62 +++++++++++++++++++---- docs/release_notes/v0.17.2.md | 26 ++++++++++ install_mac.sh | 14 +++-- install_windows.ps1 | 17 ++++--- pyproject.toml | 2 +- tests/test_prompt_files.py | 27 ++++++++++ tests/test_trinity_bridge.py | 15 ++++++ tests/voice/test_local_realtime_client.py | 57 +++++++++++++++++++-- trinity_cli.py | 2 +- 14 files changed, 275 insertions(+), 41 deletions(-) create mode 100644 core/prompt_files.py create mode 100644 docs/release_notes/v0.17.2.md create mode 100644 tests/test_prompt_files.py diff --git a/README.md b/README.md index 9679ab8..2859c7e 100644 --- a/README.md +++ b/README.md @@ -15,9 +15,9 @@ ![Trinity Assistant Banner](assets/banner.png) > [!NOTE] > **Aktuelle Highlights:** +> - **v0.17.2:** `Soul.md` und `User.md` sind gegen leere Einstellungen abgesichert. Native UI, WebUI und Companion-Bridge lehnen leere Prompttexte ab, schreiben Änderungen atomar und legen vor echten Änderungen eine private Recovery-Kopie an. Mac- und Windows-Updates übernehmen nur nichtleere Promptdateien. Eve bestätigt eine Unterbrechung nun kurz und behält den akustischen Wiedergabe-Fingerabdruck, damit sie nach einer Unterbrechung spätere Antworten wieder vollständig spricht. > - **v0.17.1:** Der lokale Eve-Desktoppfad öffnet Mikrofon und Lautsprecher jetzt als getrennte Audiostreams. Das verhindert insbesondere auf Macs mit unterschiedlichen Ein-/Ausgabe-Sampleraten einen blockierenden CoreAudio-Duplexstart. Erkannte echte Sprache leert Eves Ausgabe sofort und sendet zusätzlich ein lokales `response.cancel`; die serverseitige VAD bleibt als zweite Sicherung aktiv. > - **v0.17.0:** Eve Voice ist produktiv auf Mac, Windows und iPhone/iPad angebunden. Lokale Desktop-Gespräche laufen jetzt full-duplex über den Realtime-Pfad: Neue Sprache stoppt die laufende Antwort sofort und leert bereits gepufferte Audioausgabe. Bestehende lokale Eve-Profile werden automatisch migriert; `Legacy` bleibt unverändert als manueller und automatischer Fallback erhalten. Die Companion-App nutzt Apples Voice-Chat-Echounterdrückung und kann Eve ebenfalls per Sprache, Stopptaste oder Wischgeste unterbrechen. -> - **v0.16.70:** Die Werkstatt trennt nun fachliche Skills von tatsächlich ausführbaren Harness-Agenten. OpenCode wird mit seinem geprüften Arbeitsagenten gestartet und prüft vor jedem Auftrag OpenCode-Dienst sowie Modellserver, sodass ein nicht laufender GPU-Server sofort verständlich gemeldet wird. Die HTML-Präsentationswerkstatt führt ihre Recherche-Rolle verbindlich aus und analysiert hochgeladene Bilder sowie aus PPTX/PDF gewonnene Ansichten über einen echten Vision-Adapter, bevor Bilder einer HTML-Folie zugeordnet werden. > - Die vollstaendige Historie steht in **[RELEASES.md](RELEASES.md)** und in den detaillierten **[Release Notes](docs/release_notes/)**. > [!IMPORTANT] diff --git a/RELEASES.md b/RELEASES.md index 0fb6689..2b0614d 100644 --- a/RELEASES.md +++ b/RELEASES.md @@ -6,6 +6,7 @@ Einzelnotizen liegen unter [docs/release_notes](docs/release_notes/). ## Aktuelle Highlights +- **v0.17.2:** Private Persona-Dateien werden beim Speichern nicht mehr durch leere UI-Inhalte ersetzt. Native Einstellungen, WebUI und Companion-Bridge nutzen denselben validierten, atomaren Schreibpfad und sichern die vorherige nichtleere Fassung privat unter `TrinityRuntime/recovery/prompts`. Mac- und Windows-Installer sichern und restaurieren ausschließlich nichtleere `Soul.md` und `User.md`; neutrale Vorlagen greifen nur, wenn keine verwertbare private Datei existiert. Eves lokale Unterbrechung wartet auf vier aufeinanderfolgende Sprachblöcke, vergleicht zusätzlich das Frequenzspektrum und behält den jüngsten Wiedergabe-Fingerabdruck nach `response.cancel`. Dadurch bleibt echtes Barge-in schnell, ohne nach einer ersten Unterbrechung jede weitere Antwort selbst abzuschneiden. - **v0.17.1:** Lokale Eve-Unterhaltung auf Mac und Windows nutzt getrennte Mikrofon- und Lautsprecherstreams statt eines gekoppelten PortAudio-Duplexstreams. Das vermeidet blockierende Starts bei unterschiedlichen nativen Geräteraten und hält Ein- und Ausgabefehler voneinander isoliert. Sobald die Echo-Prüfung echte neue Sprache erkennt, leert Trinity die lokale Wiedergabe und sendet `response.cancel`, bevor die serverseitige VAD-Unterbrechung eintrifft. Legacy und Serverprofile bleiben unverändert verfügbar. - **v0.17.0:** Die optionale Eve Voice Runtime ist als produktiver, weiterhin vollständig rückrollbarer Sprachpfad für Apple-Silicon-Macs, CUDA-fähige Windows-Systeme und die iPhone-/iPad-Companion-App ausgebaut. Lokale Desktop-Profile nutzen Trinitys eigenen full-duplex Realtime-Audioclient statt des bisherigen halbduplexen Upstream-Streamers. Server-VAD bricht LLM und TTS bei neuer Sprache ab; der Client verwirft gleichzeitig noch gepufferte Eve-Ausgabe. Ein Echo-Korrelationsfilter schützt den offenen Desktop-Lautsprecherpfad, während iOS/iPadOS `voiceChat`-AEC nutzt. Alte lokale `mode: local`-Profile werden beim Laden kontrolliert migriert, ohne Modell-, Stimm- oder LLM-Einstellungen zu verändern. `Legacy` bleibt als manueller Pfad und automatischer Fehler-Fallback erhalten. Mac- und Windows-Serverprofile, getrennte Bridge-/Voice-Token, Tailscale-Port 8766 und GPU-/VM-Voraussetzungen sind neu dokumentiert. - **v0.16.70:** Werkstatt-Aufträge unterscheiden nun den fachlichen Skill vom ausführbaren Harness-Agenten. OpenCode nutzt den konfigurierten und vorab geprüften Hauptagenten – standardmäßig `build` – statt einen Skillnamen fälschlich an `--agent` zu übergeben. Vor jedem Lauf werden OpenCode-Dienst und Modellserver per kurzer Erreichbarkeitsprüfung kontrolliert; Fehler markieren auch den aktiven Arbeitsschritt korrekt als fehlgeschlagen. Die HTML-Präsentationswerkstatt führt „Agent 00: Research & Evidence“ verbindlich mit zwei Recherchezyklen und Gap-/Gegenprüfung aus. Ein neuer Vision-Adapter übernimmt eigenständige Bilder, extrahiert PPTX-Medien mit Folienbezug und rendert PDF-Seiten. Codex erhält Rasterbilder als echte `--image`-Anlagen, OpenCode als `--file`; `visual-analysis.md` ist bei visuellen Eingaben ein verbindliches Qualitätsartefakt. diff --git a/core/prompt_files.py b/core/prompt_files.py new file mode 100644 index 0000000..99a6b43 --- /dev/null +++ b/core/prompt_files.py @@ -0,0 +1,53 @@ +"""Safe persistence for Trinity's private Soul and User prompt files.""" + +from __future__ import annotations + +import os +import shutil +import uuid +from datetime import datetime +from pathlib import Path + + +class EmptyPromptError(ValueError): + """Raised when a UI tries to replace a prompt with blank content.""" + + +def validate_prompt_text(name: str, content: object) -> str: + text = str(content or "") + if not text.strip(): + raise EmptyPromptError( + f"{name} ist leer. Die vorhandene Datei wurde aus Sicherheitsgruenden nicht ersetzt." + ) + return text + + +def safe_write_prompt(path: str | Path, content: object, max_bytes: int | None = None) -> Path | None: + """Atomically write a non-empty prompt and privately back up its predecessor.""" + + target = Path(path) + text = validate_prompt_text(target.name, content) + if max_bytes is not None and len(text.encode("utf-8")) > max_bytes: + raise ValueError(f"{target.name} ist zu gross.") + + previous = target.read_text(encoding="utf-8") if target.is_file() else "" + if previous == text: + return None + + backup_path = None + if previous.strip(): + home = target.parent.parent + backup_dir = home / "TrinityRuntime" / "recovery" / "prompts" + backup_dir.mkdir(parents=True, exist_ok=True) + stamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f") + backup_path = backup_dir / f"{stamp}_{target.name}" + shutil.copy2(target, backup_path) + + target.parent.mkdir(parents=True, exist_ok=True) + temporary = target.with_name(f".{target.name}.{uuid.uuid4().hex}.tmp") + try: + temporary.write_text(text, encoding="utf-8") + os.replace(temporary, target) + finally: + temporary.unlink(missing_ok=True) + return backup_path diff --git a/core/settings_ui.py b/core/settings_ui.py index f1064e7..e9eb914 100644 --- a/core/settings_ui.py +++ b/core/settings_ui.py @@ -36,6 +36,7 @@ ) from configuration import DEFAULT_CONFIG, load_config, save_config from memory_store import MemoryStore, render_graph_html +from prompt_files import EmptyPromptError, safe_write_prompt, validate_prompt_text from trinity_paths import default_runtime_root, default_vault_root from ui_modes import resolve_ui_modes @@ -70,10 +71,16 @@ def load_text_file(self, path): return "" def save_text_file(self, path, content): - with open(path, "w", encoding="utf-8") as f: - f.write(content) + safe_write_prompt(path, content) def save_config(self, _checked=False, *, show_confirmation=True): + try: + soul_content = validate_prompt_text("Soul.md", self.soul_edit.toPlainText()) + user_content = validate_prompt_text("User.md", self.user_edit.toPlainText()) + except EmptyPromptError as exc: + QMessageBox.warning(self, "Prompt-Datei bleibt erhalten", str(exc)) + return False + # LLM Slots if "active_slot" not in self.config["llm"]: self.config["llm"]["active_slot"] = "local" @@ -378,10 +385,10 @@ def save_config(self, _checked=False, *, show_confirmation=True): save_config(self.config_path, self.config) # Soul.md speichern - self.save_text_file(self.soul_path, self.soul_edit.toPlainText()) + self.save_text_file(self.soul_path, soul_content) # User.md speichern - self.save_text_file(self.user_path, self.user_edit.toPlainText()) + self.save_text_file(self.user_path, user_content) if self.embedded and self.on_return: self.on_return(True) @@ -396,6 +403,7 @@ def save_config(self, _checked=False, *, show_confirmation=True): "nach einem Trinity-Neustart aktiv. Dieses Einstellungsfenster " "selbst hört nicht zu.", ) + return True def _return_to_chat(self): if self.embedded and self.on_return: @@ -669,7 +677,8 @@ def _sync_settings_runtime_buttons(self): ) def _save_and_start_trinity(self): - self.save_config(show_confirmation=False) + if not self.save_config(show_confirmation=False): + return home = str(Path(CORE_DIR).parent) launcher = str(Path(home) / "trinity_launcher.py") started, _pid = QProcess.startDetached(sys.executable, [launcher], home) diff --git a/core/trinity_bridge.py b/core/trinity_bridge.py index 9c778a6..7d8d50a 100644 --- a/core/trinity_bridge.py +++ b/core/trinity_bridge.py @@ -41,6 +41,7 @@ find_opencode_executable, find_pi_executable, ) +from prompt_files import safe_write_prompt from server_auth import ServerAuth from tenant_context import tenant_history_path, tenant_memory_db_path, tenant_upload_dir from trinity_paths import TrinityPaths @@ -1152,10 +1153,11 @@ def save_prompts(self, payload): for name, value in {"Soul.md": payload.get("soul"), "User.md": payload.get("user")}.items(): if value is None: continue - encoded = str(value).encode("utf-8") - if len(encoded) > MAX_SETTINGS_TEXT_BYTES: - raise ValueError(f"{name} ist zu gross.") - (self.core_dir / name).write_text(str(value), encoding="utf-8") + safe_write_prompt( + self.core_dir / name, + value, + max_bytes=MAX_SETTINGS_TEXT_BYTES, + ) return self.get_prompts() def save_web_settings(self, payload): @@ -1175,10 +1177,11 @@ def save_web_settings(self, payload): for name, value in {"Soul.md": payload.get("soul"), "User.md": payload.get("user")}.items(): if value is None: continue - encoded = str(value).encode("utf-8") - if len(encoded) > MAX_SETTINGS_TEXT_BYTES: - raise ValueError(f"{name} ist zu gross.") - (self.core_dir / name).write_text(str(value), encoding="utf-8") + safe_write_prompt( + self.core_dir / name, + value, + max_bytes=MAX_SETTINGS_TEXT_BYTES, + ) save_config(self.config_path, config) return self.get_web_settings() diff --git a/core/voice/local_realtime_client.py b/core/voice/local_realtime_client.py index 686fc59..f380524 100644 --- a/core/voice/local_realtime_client.py +++ b/core/voice/local_realtime_client.py @@ -21,6 +21,7 @@ SAMPLE_RATE = 16_000 BLOCK_SAMPLES = 512 SAMPLE_BYTES = 2 +BARGE_IN_CONFIRM_BLOCKS = 4 class LocalRealtimeAudioClient: @@ -45,6 +46,7 @@ def __init__(self, config: VoiceConfig, host: str = "127.0.0.1"): self._output = bytearray() self._output_lock = threading.Lock() self._played_output: deque[np.ndarray] = deque(maxlen=20) + self._barge_in_candidate: deque[bytes] = deque(maxlen=BARGE_IN_CONFIRM_BLOCKS) self._last_output_at = 0.0 self._last_cancel_at = 0.0 @@ -208,12 +210,35 @@ def _input_callback(self, indata, _frames, _time_info, status) -> None: if status: LOGGER.debug("Desktop-Audioeingabe: %s", status) microphone = bytes(indata) - if self._should_forward_microphone(microphone): - self._interrupt_playback_if_needed() - self._queue_event({ - "type": "input_audio_buffer.append", - "audio": base64.b64encode(microphone).decode("ascii"), - }) + output_active = (time.monotonic() - self._last_output_at) < 0.18 + if not output_active: + self._barge_in_candidate.clear() + self._append_microphone(microphone) + return + if not self._should_forward_microphone(microphone): + self._barge_in_candidate.clear() + return + + # Acoustic loudspeaker echo can differ from the exact output samples and + # occasionally looks like one distinct microphone block. Confirm a very + # short run of speech before cancelling, while retaining those blocks as + # VAD prefix. Four 32-ms blocks keep barge-in responsive (~128 ms). + self._barge_in_candidate.append(microphone) + if len(self._barge_in_candidate) < BARGE_IN_CONFIRM_BLOCKS: + return + candidate = tuple(self._barge_in_candidate) + self._barge_in_candidate.clear() + self._interrupt_playback_if_needed() + for block in candidate: + self._append_microphone(block) + + def _append_microphone(self, microphone: bytes) -> None: + if not microphone or self._stop.is_set(): + return + self._queue_event({ + "type": "input_audio_buffer.append", + "audio": base64.b64encode(microphone).decode("ascii"), + }) def _audio_callback(self, indata, outdata, frames, time_info, status) -> None: """Compatibility wrapper used by existing integrations and tests.""" @@ -250,15 +275,30 @@ def _should_forward_microphone(self, pcm: bytes) -> bool: norm = float(np.linalg.norm(samples)) if norm <= 1.0: return False - strongest = 0.0 + strongest_waveform = 0.0 + strongest_spectrum = 0.0 + spectrum = np.abs(np.fft.rfft(samples)) + spectrum_norm = float(np.linalg.norm(spectrum)) for played in self._played_output: if played.size != samples.size: continue candidate = played.astype(np.float32) denominator = norm * float(np.linalg.norm(candidate)) if denominator > 1.0: - strongest = max(strongest, abs(float(np.dot(samples, candidate) / denominator))) - return strongest < 0.62 + strongest_waveform = max( + strongest_waveform, + abs(float(np.dot(samples, candidate) / denominator)), + ) + # Magnitude spectra remain comparable despite the acoustic delay and + # phase shift introduced by loudspeakers, the room and the mic. + candidate_spectrum = np.abs(np.fft.rfft(candidate)) + spectrum_denominator = spectrum_norm * float(np.linalg.norm(candidate_spectrum)) + if spectrum_denominator > 1.0: + strongest_spectrum = max( + strongest_spectrum, + float(np.dot(spectrum, candidate_spectrum) / spectrum_denominator), + ) + return strongest_waveform < 0.62 and strongest_spectrum < 0.90 def _handle_event(self, raw: str | bytes) -> None: try: @@ -284,5 +324,7 @@ def _handle_event(self, raw: str | bytes) -> None: def _clear_output(self) -> None: with self._output_lock: self._output.clear() - self._played_output.clear() + # Keep the recent playback fingerprint. The physical speaker tail still + # reaches the microphone after the digital buffer has been cancelled; + # clearing this history made every following response interrupt itself. self._last_output_at = 0.0 diff --git a/docs/release_notes/v0.17.2.md b/docs/release_notes/v0.17.2.md new file mode 100644 index 0000000..2c7b2c4 --- /dev/null +++ b/docs/release_notes/v0.17.2.md @@ -0,0 +1,26 @@ +# Trinity v0.17.2 + +This patch release protects private persona prompts and stabilizes repeated Eve +conversations after barge-in. + +## Fixed + +- Native settings, WebUI and Companion settings reject empty `Soul.md` or + `User.md` updates instead of replacing an existing private prompt. +- Prompt updates are atomic. Before a real change, Trinity stores the previous + non-empty version privately under `TrinityRuntime/recovery/prompts`. +- macOS and Windows updates back up and restore only non-empty private prompts. + Neutral example prompts are used only when no usable private prompt exists. +- Eve now confirms four consecutive speech blocks before local barge-in and + compares both waveform and spectrum against recent speaker output. +- Cancelling one Eve response keeps the recent playback fingerprint, preventing + the physical speaker tail from cancelling every following response after its + first few words. + +## Privacy and compatibility + +- No private `Soul.md`, `User.md`, API key or voice sample is included in this + release. +- Existing non-empty prompts, Eve profiles and model settings are preserved. +- Companion v0.17.0 remains protocol-compatible; no mobile reinstall is needed. +- `Legacy` remains available manually and as the automatic voice fallback. diff --git a/install_mac.sh b/install_mac.sh index 51c55ed..e11f566 100644 --- a/install_mac.sh +++ b/install_mac.sh @@ -115,8 +115,8 @@ if [ -d "$INSTALL_DIR" ]; then # Nutzerdaten sichern [ -f "$INSTALL_DIR/core/config.json" ] && cp "$INSTALL_DIR/core/config.json" "$BACKUP_DIR/config.json" && echo " 💾 config.json gesichert" - [ -f "$INSTALL_DIR/core/Soul.md" ] && cp "$INSTALL_DIR/core/Soul.md" "$BACKUP_DIR/Soul.md" && echo " 💾 Soul.md gesichert" - [ -f "$INSTALL_DIR/core/User.md" ] && cp "$INSTALL_DIR/core/User.md" "$BACKUP_DIR/User.md" && echo " 💾 User.md gesichert" + [ -s "$INSTALL_DIR/core/Soul.md" ] && cp "$INSTALL_DIR/core/Soul.md" "$BACKUP_DIR/Soul.md" && echo " 💾 Soul.md gesichert" + [ -s "$INSTALL_DIR/core/User.md" ] && cp "$INSTALL_DIR/core/User.md" "$BACKUP_DIR/User.md" && echo " 💾 User.md gesichert" [ -d "$INSTALL_DIR/memory" ] && cp -r "$INSTALL_DIR/memory" "$BACKUP_DIR/memory" && echo " 💾 memory/ (Transkripte) gesichert" [ -d "$INSTALL_DIR/RAG" ] && cp -r "$INSTALL_DIR/RAG" "$BACKUP_DIR/RAG" && echo " 💾 RAG/ (Wissensbasis) gesichert" [ -d "$INSTALL_DIR/gen_images" ] && cp -r "$INSTALL_DIR/gen_images" "$BACKUP_DIR/gen_images" && echo " 💾 gen_images/ gesichert" @@ -155,8 +155,8 @@ if [ "$IS_UPDATE" = true ]; then echo "♻️ Stelle deine Konfigurationen wieder her..." [ -f "$BACKUP_DIR/config.json" ] && cp "$BACKUP_DIR/config.json" "$INSTALL_DIR/core/config.json" && echo " ✅ config.json wiederhergestellt" - [ -f "$BACKUP_DIR/Soul.md" ] && cp "$BACKUP_DIR/Soul.md" "$INSTALL_DIR/core/Soul.md" && echo " ✅ Soul.md wiederhergestellt" - [ -f "$BACKUP_DIR/User.md" ] && cp "$BACKUP_DIR/User.md" "$INSTALL_DIR/core/User.md" && echo " ✅ User.md wiederhergestellt" + [ -s "$BACKUP_DIR/Soul.md" ] && cp "$BACKUP_DIR/Soul.md" "$INSTALL_DIR/core/Soul.md" && echo " ✅ Soul.md wiederhergestellt" + [ -s "$BACKUP_DIR/User.md" ] && cp "$BACKUP_DIR/User.md" "$INSTALL_DIR/core/User.md" && echo " ✅ User.md wiederhergestellt" [ -d "$BACKUP_DIR/memory" ] && mkdir -p "$INSTALL_DIR/memory" && cp -a "$BACKUP_DIR/memory/." "$INSTALL_DIR/memory/" && echo " ✅ memory/ wiederhergestellt" [ -d "$BACKUP_DIR/RAG" ] && mkdir -p "$INSTALL_DIR/RAG" && cp -a "$BACKUP_DIR/RAG/." "$INSTALL_DIR/RAG/" && echo " ✅ RAG/ wiederhergestellt" [ -d "$BACKUP_DIR/gen_images" ] && mkdir -p "$INSTALL_DIR/gen_images" && cp -a "$BACKUP_DIR/gen_images/." "$INSTALL_DIR/gen_images/" && echo " ✅ gen_images/ wiederhergestellt" @@ -166,6 +166,12 @@ if [ "$IS_UPDATE" = true ]; then echo " 🛟 Wiederherstellungskopie bleibt erhalten: $RECOVERY_ROOT" fi +# Private prompts are deliberately not versioned. A fresh installation, or an +# update from already empty files, therefore starts from the neutral templates. +# Existing non-empty user prompts always take precedence via the backup above. +[ -s "$INSTALL_DIR/core/Soul.md" ] || cp "$INSTALL_DIR/core/Soul.md.example" "$INSTALL_DIR/core/Soul.md" +[ -s "$INSTALL_DIR/core/User.md" ] || cp "$INSTALL_DIR/core/User.md.example" "$INSTALL_DIR/core/User.md" + # 6. Virtuelle Umgebung erstellen & Pakete installieren echo "" echo "🐍 Erstelle virtuelle Python-Umgebung (Sandbox)..." diff --git a/install_windows.ps1 b/install_windows.ps1 index dce2dd8..83c7599 100644 --- a/install_windows.ps1 +++ b/install_windows.ps1 @@ -193,10 +193,11 @@ function Invoke-Python { function Copy-IfPresent { param( [string]$Source, - [string]$Destination + [string]$Destination, + [switch]$RequireContent ) - if (Test-Path $Source) { + if ((Test-Path $Source) -and ((-not $RequireContent) -or ((Get-Item $Source).Length -gt 0))) { Copy-Item $Source $Destination -Recurse -Force } } @@ -285,8 +286,8 @@ if ($isUpdate) { New-Item -ItemType Directory -Path $backupDir -Force | Out-Null Copy-IfPresent "$InstallDir\core\config.json" "$backupDir\config.json" - Copy-IfPresent "$InstallDir\core\Soul.md" "$backupDir\Soul.md" - Copy-IfPresent "$InstallDir\core\User.md" "$backupDir\User.md" + Copy-IfPresent "$InstallDir\core\Soul.md" "$backupDir\Soul.md" -RequireContent + Copy-IfPresent "$InstallDir\core\User.md" "$backupDir\User.md" -RequireContent Copy-DirectoryContents "$InstallDir\memory" "$backupDir\memory" Copy-DirectoryContents "$InstallDir\RAG" "$backupDir\RAG" Copy-DirectoryContents "$InstallDir\gen_images" "$backupDir\gen_images" @@ -329,8 +330,8 @@ else { if ($isUpdate) { Write-Host "Stelle Nutzerdaten wieder her ..." Copy-IfPresent "$backupDir\config.json" "$InstallDir\core\config.json" - Copy-IfPresent "$backupDir\Soul.md" "$InstallDir\core\Soul.md" - Copy-IfPresent "$backupDir\User.md" "$InstallDir\core\User.md" + Copy-IfPresent "$backupDir\Soul.md" "$InstallDir\core\Soul.md" -RequireContent + Copy-IfPresent "$backupDir\User.md" "$InstallDir\core\User.md" -RequireContent Copy-DirectoryContents "$backupDir\memory" "$InstallDir\memory" Copy-DirectoryContents "$backupDir\RAG" "$InstallDir\RAG" @("build_index.py") Copy-DirectoryContents "$backupDir\gen_images" "$InstallDir\gen_images" @@ -339,10 +340,10 @@ if ($isUpdate) { Remove-Item $backupDir -Recurse -Force } -if (-not (Test-Path "$InstallDir\core\Soul.md")) { +if ((-not (Test-Path "$InstallDir\core\Soul.md")) -or ((Get-Item "$InstallDir\core\Soul.md").Length -eq 0)) { Copy-Item "$InstallDir\core\Soul.md.example" "$InstallDir\core\Soul.md" } -if (-not (Test-Path "$InstallDir\core\User.md")) { +if ((-not (Test-Path "$InstallDir\core\User.md")) -or ((Get-Item "$InstallDir\core\User.md").Length -eq 0)) { Copy-Item "$InstallDir\core\User.md.example" "$InstallDir\core\User.md" } diff --git a/pyproject.toml b/pyproject.toml index 64938da..9bbf3dd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "trinity-assistant" -version = "0.17.1" +version = "0.17.2" description = "Local-first academic personal concierge for macOS, Windows 11, and Linux servers" readme = "README.md" requires-python = ">=3.10,<3.15" diff --git a/tests/test_prompt_files.py b/tests/test_prompt_files.py new file mode 100644 index 0000000..61233ca --- /dev/null +++ b/tests/test_prompt_files.py @@ -0,0 +1,27 @@ +import pytest + +from prompt_files import EmptyPromptError, safe_write_prompt + + +def test_blank_prompt_cannot_replace_existing_private_prompt(tmp_path): + target = tmp_path / "core" / "Soul.md" + target.parent.mkdir() + target.write_text("Trinity bleibt", encoding="utf-8") + + with pytest.raises(EmptyPromptError): + safe_write_prompt(target, " \n") + + assert target.read_text(encoding="utf-8") == "Trinity bleibt" + + +def test_prompt_update_is_atomic_and_creates_private_recovery_copy(tmp_path): + target = tmp_path / "core" / "User.md" + target.parent.mkdir() + target.write_text("Vorher", encoding="utf-8") + + backup = safe_write_prompt(target, "Nachher") + + assert target.read_text(encoding="utf-8") == "Nachher" + assert backup is not None + assert backup.read_text(encoding="utf-8") == "Vorher" + assert backup.parent == tmp_path / "TrinityRuntime" / "recovery" / "prompts" diff --git a/tests/test_trinity_bridge.py b/tests/test_trinity_bridge.py index d9fd2a4..b9fc7af 100644 --- a/tests/test_trinity_bridge.py +++ b/tests/test_trinity_bridge.py @@ -3,6 +3,8 @@ import sys import time +import pytest + from trinity_bridge import TrinityBridge, _local_path_value from web_ui import render_web_ui from chat_protocol import append_chat_event, load_chat_events, pop_next_chat_request @@ -277,6 +279,19 @@ def test_bridge_prompts_include_persona_wakeword_variants(tmp_path): assert prompts["trigger_variants"] == ["trinity", "triniti"] +def test_bridge_rejects_blank_prompt_overwrite(tmp_path): + home = tmp_path + (home / "core").mkdir() + (home / "core" / "Soul.md").write_text("Systemprompt", encoding="utf-8") + (home / "core" / "User.md").write_text("Userprompt", encoding="utf-8") + bridge = TrinityBridge(home) + + with pytest.raises(ValueError, match="Soul.md ist leer"): + bridge.save_prompts({"soul": "", "user": "Userprompt"}) + + assert (home / "core" / "Soul.md").read_text(encoding="utf-8") == "Systemprompt" + + def test_bridge_exposes_and_mutates_workspace_state(tmp_path): home = tmp_path (home / "core").mkdir() diff --git a/tests/voice/test_local_realtime_client.py b/tests/voice/test_local_realtime_client.py index 645dbd4..6c67cee 100644 --- a/tests/voice/test_local_realtime_client.py +++ b/tests/voice/test_local_realtime_client.py @@ -72,12 +72,63 @@ def test_distinct_speech_cancels_current_response_before_forwarding(tmp_path): local._last_output_at = time.monotonic() speech = np.tile(np.array([1_400, -1_100, 800, -500], dtype=np.int16), 128) - local._input_callback(speech.tobytes(), 512, None, None) + for _ in range(4): + local._input_callback(speech.tobytes(), 512, None, None) assert local._output == bytearray() assert local._send_queue.get_nowait() == {"type": "response.cancel"} - forwarded = local._send_queue.get_nowait() - assert forwarded["type"] == "input_audio_buffer.append" + forwarded = [local._send_queue.get_nowait() for _ in range(4)] + assert all(event["type"] == "input_audio_buffer.append" for event in forwarded) + + +def test_single_distinct_block_does_not_false_trigger_barge_in(tmp_path): + local = client(tmp_path) + local._output.extend(b"buffered-audio") + local._played_output.append(np.full(512, 2_000, dtype=np.int16)) + local._last_output_at = time.monotonic() + speech = np.tile(np.array([1_400, -1_100, 800, -500], dtype=np.int16), 128) + + local._input_callback(speech.tobytes(), 512, None, None) + + assert local._output == bytearray(b"buffered-audio") + assert local._send_queue.empty() + + +def test_interrupt_keeps_echo_history_for_the_speaker_tail(tmp_path): + local = client(tmp_path) + playback = np.tile(np.array([1_500, -1_000, 600, -300], dtype=np.int16), 128) + local._played_output.append(playback.copy()) + local._output.extend(playback.tobytes()) + local._last_output_at = time.monotonic() + + local._clear_output() + local._last_output_at = time.monotonic() + + assert len(local._played_output) == 1 + assert local._should_forward_microphone(playback.tobytes()) is False + + +def test_completed_barge_in_does_not_cancel_the_following_response(tmp_path): + local = client(tmp_path) + playback = np.tile(np.array([1_500, -1_000, 600, -300], dtype=np.int16), 128) + speech = np.tile(np.array([900, 1_600, -1_300, -700], dtype=np.int16), 128) + local._played_output.append(playback.copy()) + local._output.extend(playback.tobytes()) + local._last_output_at = time.monotonic() + + for _ in range(4): + local._input_callback(speech.tobytes(), 512, None, None) + while not local._send_queue.empty(): + local._send_queue.get_nowait() + + local._played_output.append(playback.copy()) + local._output.extend(playback.tobytes()) + local._last_output_at = time.monotonic() + for _ in range(10): + local._input_callback(playback.tobytes(), 512, None, None) + + assert local._send_queue.empty() + assert local._output == bytearray(playback.tobytes()) def test_output_callback_consumes_audio_without_microphone_coupling(tmp_path): diff --git a/trinity_cli.py b/trinity_cli.py index 7b17037..e36edfc 100644 --- a/trinity_cli.py +++ b/trinity_cli.py @@ -10,7 +10,7 @@ from pathlib import Path -VERSION = "0.17.1" +VERSION = "0.17.2" def find_trinity_home(explicit=None):