Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
1 change: 1 addition & 0 deletions RELEASES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
53 changes: 53 additions & 0 deletions core/prompt_files.py
Original file line number Diff line number Diff line change
@@ -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
19 changes: 14 additions & 5 deletions core/settings_ui.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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)
Expand All @@ -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:
Expand Down Expand Up @@ -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)
Expand Down
19 changes: 11 additions & 8 deletions core/trinity_bridge.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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):
Expand All @@ -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()
Expand Down
62 changes: 52 additions & 10 deletions core/voice/local_realtime_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
SAMPLE_RATE = 16_000
BLOCK_SAMPLES = 512
SAMPLE_BYTES = 2
BARGE_IN_CONFIRM_BLOCKS = 4


class LocalRealtimeAudioClient:
Expand All @@ -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

Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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:
Expand All @@ -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
26 changes: 26 additions & 0 deletions docs/release_notes/v0.17.2.md
Original file line number Diff line number Diff line change
@@ -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.
Loading
Loading