Compare commits

..

7 Commits

Author SHA1 Message Date
beec49a92d Merge branch 'main' into claude/issue-1126
Some checks failed
CI / test (pull_request) Failing after 10s
CI / validate (pull_request) Failing after 11s
Review Approval Gate / verify-review (pull_request) Successful in 2s
2026-04-10 09:37:45 +00:00
ef25c073ce Merge pull request '[Mnemosyne] Consolidated Spatial Memory Schema — spatial regions + demo memories' (#1156) from feat/mnemosyne-spatial-schema-consolidated into main
Some checks failed
Deploy Nexus / deploy (push) Failing after 3s
Staging Verification Gate / verify-staging (push) Failing after 3s
Merge PR #1156: [Mnemosyne] Consolidated Spatial Memory Schema — spatial regions + demo memories
2026-04-10 09:35:23 +00:00
5ce928a00d feat(mnemosyne): integrate SpatialMemory — import, init, update loop, demo seeds
Some checks failed
CI / test (pull_request) Failing after 9s
CI / validate (pull_request) Failing after 13s
Review Approval Gate / verify-review (pull_request) Failing after 4s
2026-04-10 07:41:13 +00:00
61871cf6ed feat(mnemosyne): add SpatialMemory component with 6 semantic regions 2026-04-10 07:36:53 +00:00
6f949698fe Merge pull request #1148
Some checks failed
Deploy Nexus / deploy (push) Failing after 3s
Staging Verification Gate / verify-staging (push) Failing after 5s
Merged PR #1148
2026-04-10 03:43:56 +00:00
6cf1f4d078 feat(mnemosyne): implement memory orb system with game loop integration\n\n- Added spawnMemoryOrb() with PBR materials and point lighting\n- Added removeMemoryOrb() with proper resource disposal\n- Added animateMemoryOrbs() for pulse/fade animation\n- Added spawnRetrievalOrbs() for RAG result visualization\n- Integrated animateMemoryOrbs(delta) into gameLoop()\n- Orbs auto-fade after 30s with smooth 10s fade-out\n\nFixes #1147\nSupersedes PR #1147 (blocked by branch protection)
Some checks failed
CI / test (pull_request) Failing after 9s
CI / validate (pull_request) Failing after 19s
Review Approval Gate / verify-review (pull_request) Successful in 4s
2026-04-10 02:13:31 +00:00
Alexander Whitestone
ef74536e33 feat: add edge-tts as zero-cost voice output provider
Some checks failed
CI / test (pull_request) Failing after 33s
CI / validate (pull_request) Failing after 26s
Review Approval Gate / verify-review (pull_request) Failing after 5s
- Add EdgeTTSAdapter to bin/deepdive_tts.py (provider key: "edge-tts")
  default voice: en-US-GuyNeural, no API key required
- Add EdgeTTS class to intelligence/deepdive/tts_engine.py
- Update HybridTTS to try edge-tts as fallback between piper and elevenlabs
- Add --voice-memo flag to bin/night_watch.py for spoken nightly reports
- Add edge-tts>=6.1.9 to requirements.txt
- Create docs/voice-output.md documenting all providers and fallback chain
- Add tests/test_edge_tts.py with 17 unit tests (all mocked, no network)

Fixes #1126

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 06:29:26 -04:00
22 changed files with 1244 additions and 208 deletions

171
app.js
View File

@@ -3,6 +3,7 @@ import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js';
import { RenderPass } from 'three/addons/postprocessing/RenderPass.js';
import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js';
import { SMAAPass } from 'three/addons/postprocessing/SMAAPass.js';
import { SpatialMemory } from './nexus/components/spatial-memory.js';
// ═══════════════════════════════════════════
// NEXUS v1.1 — Portal System Update
@@ -703,6 +704,7 @@ async function init() {
createSessionPowerMeter();
createWorkshopTerminal();
createAshStorm();
SpatialMemory.init(scene);
updateLoad(90);
loadSession();
@@ -2573,6 +2575,13 @@ function gameLoop() {
updateAshStorm(delta, elapsed);
// Project Mnemosyne - Memory Orb Animation
if (typeof animateMemoryOrbs === 'function') {
SpatialMemory.update(delta);
animateMemoryOrbs(delta);
}
const mode = NAV_MODES[navModeIdx];
const chatActive = document.activeElement === document.getElementById('chat-input');
@@ -2771,6 +2780,12 @@ function gameLoop() {
composer.render();
updateAshStorm(delta, elapsed);
// Project Mnemosyne - Memory Orb Animation
if (typeof animateMemoryOrbs === 'function') {
animateMemoryOrbs(delta);
}
updatePortalTunnel(delta, elapsed);
if (workshopScanMat) workshopScanMat.uniforms.uTime.value = clock.getElapsedTime();
@@ -2933,9 +2948,165 @@ function updateAshStorm(delta, elapsed) {
}
}
// ═══════════════════════════════════════════
// PROJECT MNEMOSYNE — HOLOGRAPHIC MEMORY ORBS
// ═══════════════════════════════════════════
// Memory orbs registry for animation loop
const memoryOrbs = [];
/**
* Spawn a glowing memory orb at the given position.
* Used to visualize RAG retrievals and memory recalls in the Nexus.
*
* @param {THREE.Vector3} position - World position for the orb
* @param {number} color - Hex color (default: 0x4af0c0 - cyan)
* @param {number} size - Radius of the orb (default: 0.5)
* @param {object} metadata - Optional metadata for the memory (source, timestamp, etc.)
* @returns {THREE.Mesh} The created orb mesh
*/
function spawnMemoryOrb(position, color = 0x4af0c0, size = 0.5, metadata = {}) {
if (typeof THREE === 'undefined' || typeof scene === 'undefined') {
console.warn('[Mnemosyne] THREE/scene not available for orb spawn');
return null;
}
const geometry = new THREE.SphereGeometry(size, 32, 32);
const material = new THREE.MeshStandardMaterial({
color: color,
emissive: color,
emissiveIntensity: 2.5,
metalness: 0.3,
roughness: 0.2,
transparent: true,
opacity: 0.85,
envMapIntensity: 1.5
});
const orb = new THREE.Mesh(geometry, material);
orb.position.copy(position);
orb.castShadow = true;
orb.receiveShadow = true;
orb.userData = {
type: 'memory_orb',
pulse: Math.random() * Math.PI * 2, // Random phase offset
pulseSpeed: 0.002 + Math.random() * 0.001,
originalScale: size,
metadata: metadata,
createdAt: Date.now()
};
// Point light for local illumination
const light = new THREE.PointLight(color, 1.5, 8);
orb.add(light);
scene.add(orb);
memoryOrbs.push(orb);
console.info('[Mnemosyne] Memory orb spawned:', metadata.source || 'unknown');
return orb;
}
/**
* Remove a memory orb from the scene and dispose resources.
* @param {THREE.Mesh} orb - The orb to remove
*/
function removeMemoryOrb(orb) {
if (!orb) return;
if (orb.parent) orb.parent.remove(orb);
if (orb.geometry) orb.geometry.dispose();
if (orb.material) orb.material.dispose();
const idx = memoryOrbs.indexOf(orb);
if (idx > -1) memoryOrbs.splice(idx, 1);
}
/**
* Animate all memory orbs — pulse, rotate, and fade.
* Called from gameLoop() every frame.
* @param {number} delta - Time since last frame
*/
function animateMemoryOrbs(delta) {
for (let i = memoryOrbs.length - 1; i >= 0; i--) {
const orb = memoryOrbs[i];
if (!orb || !orb.userData) continue;
// Pulse animation
orb.userData.pulse += orb.userData.pulseSpeed * delta * 1000;
const pulseFactor = 1 + Math.sin(orb.userData.pulse) * 0.1;
orb.scale.setScalar(pulseFactor * orb.userData.originalScale);
// Gentle rotation
orb.rotation.y += delta * 0.5;
// Fade after 30 seconds
const age = (Date.now() - orb.userData.createdAt) / 1000;
if (age > 30) {
const fadeDuration = 10;
const fadeProgress = Math.min(1, (age - 30) / fadeDuration);
orb.material.opacity = 0.85 * (1 - fadeProgress);
if (fadeProgress >= 1) {
removeMemoryOrb(orb);
i--; // Adjust index after removal
}
}
}
}
/**
* Spawn memory orbs arranged in a spiral for RAG retrieval results.
* @param {Array} results - Array of {content, score, source}
* @param {THREE.Vector3} center - Center position (default: above avatar)
*/
function spawnRetrievalOrbs(results, center) {
if (!results || !Array.isArray(results) || results.length === 0) return;
if (!center) {
center = new THREE.Vector3(0, 2, 0);
}
const colors = [0x4af0c0, 0x7b5cff, 0xffd700, 0xff4466, 0x00ff88];
const radius = 3;
results.forEach((result, i) => {
const angle = (i / results.length) * Math.PI * 2;
const height = (i / results.length) * 2 - 1;
const position = new THREE.Vector3(
center.x + Math.cos(angle) * radius,
center.y + height,
center.z + Math.sin(angle) * radius
);
const colorIdx = Math.min(colors.length - 1, Math.floor((result.score || 0.5) * colors.length));
const size = 0.3 + (result.score || 0.5) * 0.4;
spawnMemoryOrb(position, colors[colorIdx], size, {
source: result.source || 'unknown',
score: result.score || 0,
contentPreview: (result.content || '').substring(0, 100)
});
});
}
init().then(() => {
createAshStorm();
createPortalTunnel();
// Project Mnemosyne — seed demo spatial memories
const demoMemories = [
{ id: 'mem_nexus_birth', content: 'The Nexus came online — first render of the 3D world', category: 'knowledge', strength: 0.95, connections: ['mem_mnemosyne_start'] },
{ id: 'mem_first_portal', content: 'First portal deployed — connection to external service', category: 'engineering', strength: 0.85, connections: ['mem_nexus_birth'] },
{ id: 'mem_hermes_chat', content: 'First conversation through the Hermes gateway', category: 'social', strength: 0.7, connections: [] },
{ id: 'mem_mnemosyne_start', content: 'Project Mnemosyne began — the living archive awakens', category: 'projects', strength: 0.9, connections: ['mem_nexus_birth', 'mem_spatial_schema'] },
{ id: 'mem_spatial_schema', content: 'Spatial Memory Schema defined — memories gain permanent homes', category: 'engineering', strength: 0.8, connections: ['mem_mnemosyne_start'] },
];
demoMemories.forEach(m => SpatialMemory.placeMemory(m));
fetchGiteaData();
setInterval(fetchGiteaData, 30000);
runWeeklyAudit();

Binary file not shown.

View File

@@ -152,17 +152,55 @@ class OpenAITTSAdapter:
return mp3_path
class EdgeTTSAdapter:
"""Zero-cost TTS using Microsoft Edge neural voices (no API key required).
Requires: pip install edge-tts>=6.1.9
Voices: https://learn.microsoft.com/en-us/azure/ai-services/speech-service/language-support
"""
DEFAULT_VOICE = "en-US-GuyNeural"
def __init__(self, config: TTSConfig):
self.config = config
self.voice = config.voice_id or self.DEFAULT_VOICE
def synthesize(self, text: str, output_path: Path) -> Path:
try:
import edge_tts
except ImportError:
raise RuntimeError("edge-tts not installed. Run: pip install edge-tts")
import asyncio
mp3_path = output_path.with_suffix(".mp3")
async def _run():
communicate = edge_tts.Communicate(text, self.voice)
await communicate.save(str(mp3_path))
asyncio.run(_run())
return mp3_path
ADAPTERS = {
"piper": PiperAdapter,
"elevenlabs": ElevenLabsAdapter,
"openai": OpenAITTSAdapter,
"edge-tts": EdgeTTSAdapter,
}
def get_provider_config() -> TTSConfig:
"""Load TTS configuration from environment."""
provider = os.environ.get("DEEPDIVE_TTS_PROVIDER", "openai")
voice = os.environ.get("DEEPDIVE_TTS_VOICE", "alloy" if provider == "openai" else "matthew")
if provider == "openai":
default_voice = "alloy"
elif provider == "edge-tts":
default_voice = EdgeTTSAdapter.DEFAULT_VOICE
else:
default_voice = "matthew"
voice = os.environ.get("DEEPDIVE_TTS_VOICE", default_voice)
return TTSConfig(
provider=provider,

View File

@@ -32,12 +32,14 @@ import importlib.util
import json
import logging
import os
import re
import shutil
import subprocess
import sys
import time
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
logging.basicConfig(
level=logging.INFO,
@@ -212,6 +214,46 @@ def generate_report(date_str: str, checker_mod) -> str:
return "\n".join(lines)
# ── Voice memo ────────────────────────────────────────────────────────
def _generate_voice_memo(report_text: str, date_str: str) -> Optional[str]:
"""Generate an MP3 voice memo of the night watch report.
Returns the output path on success, or None if generation fails.
"""
try:
import edge_tts
except ImportError:
logger.warning("edge-tts not installed; skipping voice memo. Run: pip install edge-tts")
return None
import asyncio
# Strip markdown formatting for cleaner speech
clean = report_text
clean = re.sub(r"#+\s*", "", clean) # headings
clean = re.sub(r"\|", " ", clean) # table pipes
clean = re.sub(r"\*+", "", clean) # bold/italic markers
clean = re.sub(r"-{3,}", "", clean) # horizontal rules
clean = re.sub(r"\s{2,}", " ", clean) # collapse extra whitespace
output_dir = Path("/tmp/bezalel")
output_dir.mkdir(parents=True, exist_ok=True)
mp3_path = output_dir / f"night-watch-{date_str}.mp3"
try:
async def _run():
communicate = edge_tts.Communicate(clean.strip(), "en-US-GuyNeural")
await communicate.save(str(mp3_path))
asyncio.run(_run())
logger.info("Voice memo written to %s", mp3_path)
return str(mp3_path)
except Exception as exc:
logger.warning("Voice memo generation failed: %s", exc)
return None
# ── Entry point ───────────────────────────────────────────────────────
def main() -> None:
@@ -226,6 +268,10 @@ def main() -> None:
"--dry-run", action="store_true",
help="Print report to stdout instead of writing to disk",
)
parser.add_argument(
"--voice-memo", action="store_true",
help="Generate an MP3 voice memo of the report using edge-tts (saved to /tmp/bezalel/)",
)
args = parser.parse_args()
date_str = args.date or datetime.now(timezone.utc).strftime("%Y-%m-%d")
@@ -242,6 +288,14 @@ def main() -> None:
report_path.write_text(report_text)
logger.info("Night Watch report written to %s", report_path)
if args.voice_memo:
try:
memo_path = _generate_voice_memo(report_text, date_str)
if memo_path:
logger.info("Voice memo: %s", memo_path)
except Exception as exc:
logger.warning("Voice memo failed (non-fatal): %s", exc)
if __name__ == "__main__":
main()

135
docs/voice-output.md Normal file
View File

@@ -0,0 +1,135 @@
# Voice Output System
## Overview
The Nexus voice output system converts text reports and briefings into spoken audio.
It supports multiple TTS providers with automatic fallback so that audio generation
degrades gracefully when a provider is unavailable.
Primary use cases:
- **Deep Dive** daily briefings (`bin/deepdive_tts.py`)
- **Night Watch** nightly reports (`bin/night_watch.py --voice-memo`)
---
## Available Providers
### edge-tts (recommended default)
- **Cost:** Zero — no API key, no account required
- **Package:** `pip install edge-tts>=6.1.9`
- **Default voice:** `en-US-GuyNeural`
- **Output format:** MP3
- **How it works:** Streams audio from Microsoft Edge's neural TTS service over HTTPS.
No local model download required.
- **Available locales:** 100+ languages and locales. Full list:
https://learn.microsoft.com/en-us/azure/ai-services/speech-service/language-support
Notable English voices:
| Voice ID | Style |
|---|---|
| `en-US-GuyNeural` | Neutral male (default) |
| `en-US-JennyNeural` | Warm female |
| `en-US-AriaNeural` | Expressive female |
| `en-GB-RyanNeural` | British male |
### piper
- **Cost:** Free, fully offline
- **Package:** `pip install piper-tts` + model download (~65 MB)
- **Model location:** `~/.local/share/piper/en_US-lessac-medium.onnx`
- **Output format:** WAV → MP3 (requires `lame`)
- **Sovereignty:** Fully local; no network calls after model download
### elevenlabs
- **Cost:** Usage-based (paid)
- **Requirement:** `ELEVENLABS_API_KEY` environment variable
- **Output format:** MP3
- **Quality:** Highest quality of the three providers
### openai
- **Cost:** Usage-based (paid)
- **Requirement:** `OPENAI_API_KEY` environment variable
- **Output format:** MP3
- **Default voice:** `alloy`
---
## Usage: deepdive_tts.py
```bash
# Use edge-tts (zero cost)
DEEPDIVE_TTS_PROVIDER=edge-tts python bin/deepdive_tts.py --text "Good morning."
# Specify a different Edge voice
python bin/deepdive_tts.py --provider edge-tts --voice en-US-JennyNeural --text "Hello world."
# Read from a file
python bin/deepdive_tts.py --provider edge-tts --input-file /tmp/briefing.txt --output /tmp/briefing
# Use OpenAI
OPENAI_API_KEY=sk-... python bin/deepdive_tts.py --provider openai --voice nova --text "Hello."
# Use ElevenLabs
ELEVENLABS_API_KEY=... python bin/deepdive_tts.py --provider elevenlabs --voice rachel --text "Hello."
# Use local Piper (offline)
python bin/deepdive_tts.py --provider piper --text "Hello."
```
Provider and voice can also be set via environment variables:
```bash
export DEEPDIVE_TTS_PROVIDER=edge-tts
export DEEPDIVE_TTS_VOICE=en-GB-RyanNeural
python bin/deepdive_tts.py --text "Good evening."
```
---
## Usage: Night Watch --voice-memo
The `--voice-memo` flag causes Night Watch to generate an MP3 audio summary of the
nightly report immediately after writing the markdown file.
```bash
python bin/night_watch.py --voice-memo
```
Output location: `/tmp/bezalel/night-watch-<YYYY-MM-DD>.mp3`
The voice memo:
- Strips markdown formatting (`#`, `|`, `*`, `---`) for cleaner speech
- Uses `edge-tts` with the `en-US-GuyNeural` voice
- Is non-fatal: if TTS fails, the markdown report is still written normally
Example crontab with voice memo:
```cron
0 3 * * * cd /path/to/the-nexus && python bin/night_watch.py --voice-memo \
>> /var/log/bezalel/night-watch.log 2>&1
```
---
## Fallback Chain
`HybridTTS` (used by `tts_engine.py`) attempts providers in this order:
1. **edge-tts** — zero cost, no API key
2. **piper** — offline local model (if model file present)
3. **elevenlabs** — cloud fallback (if `ELEVENLABS_API_KEY` set)
If `prefer_cloud=True` is passed, the order becomes: elevenlabs → piper.
---
## Phase 3 TODO
Evaluate **fish-speech** and **F5-TTS** as fully offline, sovereign alternatives
with higher voice quality than Piper. These models run locally with no network
dependency whatsoever, providing complete independence from Microsoft's Edge service.
Tracking: to be filed as a follow-up to issue #830.

View File

@@ -157,14 +157,45 @@ class ElevenLabsTTS:
return output_path
class EdgeTTS:
"""Zero-cost TTS using Microsoft Edge neural voices (no API key required).
Requires: pip install edge-tts>=6.1.9
"""
DEFAULT_VOICE = "en-US-GuyNeural"
def __init__(self, voice: str = None):
self.voice = voice or self.DEFAULT_VOICE
def synthesize(self, text: str, output_path: str) -> str:
"""Convert text to MP3 via Edge TTS."""
try:
import edge_tts
except ImportError:
raise RuntimeError("edge-tts not installed. Run: pip install edge-tts")
import asyncio
from pathlib import Path
mp3_path = str(Path(output_path).with_suffix(".mp3"))
async def _run():
communicate = edge_tts.Communicate(text, self.voice)
await communicate.save(mp3_path)
asyncio.run(_run())
return mp3_path
class HybridTTS:
"""TTS with sovereign primary, cloud fallback."""
def __init__(self, prefer_cloud: bool = False):
self.primary = None
self.fallback = None
self.prefer_cloud = prefer_cloud
# Try preferred engine
if prefer_cloud:
self._init_elevenlabs()
@@ -172,21 +203,29 @@ class HybridTTS:
self._init_piper()
else:
self._init_piper()
if not self.primary:
self._init_edge_tts()
if not self.primary:
self._init_elevenlabs()
def _init_piper(self):
try:
self.primary = PiperTTS()
except Exception as e:
print(f"Piper init failed: {e}")
def _init_edge_tts(self):
try:
self.primary = EdgeTTS()
except Exception as e:
print(f"EdgeTTS init failed: {e}")
def _init_elevenlabs(self):
try:
self.primary = ElevenLabsTTS()
except Exception as e:
print(f"ElevenLabs init failed: {e}")
def synthesize(self, text: str, output_path: str) -> str:
"""Synthesize with fallback."""
if self.primary:
@@ -194,7 +233,7 @@ class HybridTTS:
return self.primary.synthesize(text, output_path)
except Exception as e:
print(f"Primary failed: {e}")
raise RuntimeError("No TTS engine available")

View File

@@ -1,6 +1,6 @@
meta:
version: 1.0.0
updated_at: '2026-04-08T23:16:01.923739+00:00'
updated_at: '2026-04-07T18:43:13.675019+00:00'
next_review: '2026-04-14T02:55:00Z'
fleet:
bezalel:
@@ -86,12 +86,12 @@ provider_health_matrix:
kimi-coding:
status: healthy
note: ''
last_checked: '2026-04-08T23:16:01.923511+00:00'
last_checked: '2026-04-07T18:43:13.674848+00:00'
rate_limited: false
dead: false
anthropic:
status: healthy
last_checked: '2026-04-08T23:16:01.923714+00:00'
last_checked: '2026-04-07T18:43:13.675004+00:00'
rate_limited: false
dead: false
note: ''

View File

@@ -0,0 +1,376 @@
// ═══════════════════════════════════════════
// PROJECT MNEMOSYNE — SPATIAL MEMORY SCHEMA
// ═══════════════════════════════════════════
//
// Maps memories to persistent locations in the 3D Nexus world.
// Each region corresponds to a semantic category. Memories placed
// in a region stay there across sessions, forming a navigable
// holographic archive.
//
// World layout (hex cylinder, radius 25):
// North (z-) → Documents & Knowledge
// South (z+) → Projects & Tasks
// East (x+) → Code & Engineering
// West (x-) → Conversations & Social
// Center → Active Working Memory
// Below (y-) → Archive (cold storage)
//
// Usage from app.js:
// SpatialMemory.init(scene);
// SpatialMemory.placeMemory({ id, content, category, ... });
// SpatialMemory.importIndex(savedIndex);
// SpatialMemory.update(delta);
// ═══════════════════════════════════════════
const SpatialMemory = (() => {
// ─── REGION DEFINITIONS ───────────────────────────────
const REGIONS = {
engineering: {
label: 'Code & Engineering',
center: [15, 0, 0],
radius: 10,
color: 0x4af0c0,
glyph: '\u2699',
description: 'Source code, debugging sessions, architecture decisions'
},
social: {
label: 'Conversations & Social',
center: [-15, 0, 0],
radius: 10,
color: 0x7b5cff,
glyph: '\uD83D\uDCAC',
description: 'Chats, discussions, human interactions'
},
knowledge: {
label: 'Documents & Knowledge',
center: [0, 0, -15],
radius: 10,
color: 0xffd700,
glyph: '\uD83D\uDCD6',
description: 'Papers, docs, research, learned concepts'
},
projects: {
label: 'Projects & Tasks',
center: [0, 0, 15],
radius: 10,
color: 0xff4466,
glyph: '\uD83C\uDFAF',
description: 'Active tasks, issues, milestones, goals'
},
working: {
label: 'Active Working Memory',
center: [0, 0, 0],
radius: 5,
color: 0x00ff88,
glyph: '\uD83D\uDCA1',
description: 'Current focus — transient, high-priority memories'
},
archive: {
label: 'Archive',
center: [0, -3, 0],
radius: 20,
color: 0x334455,
glyph: '\uD83D\uDDC4',
description: 'Cold storage — rarely accessed, aged-out memories'
}
};
// ─── STATE ────────────────────────────────────────────
let _scene = null;
let _regionMarkers = {};
let _memoryObjects = {};
let _connectionLines = [];
let _initialized = false;
// ─── CRYSTAL GEOMETRY (persistent memories) ───────────
function createCrystalGeometry(size) {
return new THREE.OctahedronGeometry(size, 0);
}
// ─── REGION MARKER ───────────────────────────────────
function createRegionMarker(regionKey, region) {
const cx = region.center[0];
const cy = region.center[1] + 0.06;
const cz = region.center[2];
const ringGeo = new THREE.RingGeometry(region.radius - 0.5, region.radius, 6);
const ringMat = new THREE.MeshBasicMaterial({
color: region.color,
transparent: true,
opacity: 0.15,
side: THREE.DoubleSide
});
const ring = new THREE.Mesh(ringGeo, ringMat);
ring.rotation.x = -Math.PI / 2;
ring.position.set(cx, cy, cz);
ring.userData = { type: 'region_marker', region: regionKey };
const discGeo = new THREE.CircleGeometry(region.radius - 0.5, 6);
const discMat = new THREE.MeshBasicMaterial({
color: region.color,
transparent: true,
opacity: 0.03,
side: THREE.DoubleSide
});
const disc = new THREE.Mesh(discGeo, discMat);
disc.rotation.x = -Math.PI / 2;
disc.position.set(cx, cy - 0.01, cz);
_scene.add(ring);
_scene.add(disc);
// Floating label
const canvas = document.createElement('canvas');
canvas.width = 256;
canvas.height = 64;
const ctx = canvas.getContext('2d');
ctx.font = '24px monospace';
ctx.fillStyle = '#' + region.color.toString(16).padStart(6, '0');
ctx.textAlign = 'center';
ctx.fillText(region.glyph + ' ' + region.label, 128, 40);
const texture = new THREE.CanvasTexture(canvas);
const spriteMat = new THREE.SpriteMaterial({ map: texture, transparent: true, opacity: 0.6 });
const sprite = new THREE.Sprite(spriteMat);
sprite.position.set(cx, 3, cz);
sprite.scale.set(4, 1, 1);
_scene.add(sprite);
return { ring, disc, sprite };
}
// ─── PLACE A MEMORY ──────────────────────────────────
function placeMemory(mem) {
if (!_scene) return null;
const region = REGIONS[mem.category] || REGIONS.working;
const pos = mem.position || _assignPosition(mem.category, mem.id);
const strength = Math.max(0.05, Math.min(1, mem.strength != null ? mem.strength : 0.7));
const size = 0.2 + strength * 0.3;
const geo = createCrystalGeometry(size);
const mat = new THREE.MeshStandardMaterial({
color: region.color,
emissive: region.color,
emissiveIntensity: 1.5 * strength,
metalness: 0.6,
roughness: 0.15,
transparent: true,
opacity: 0.5 + strength * 0.4
});
const crystal = new THREE.Mesh(geo, mat);
crystal.position.set(pos[0], pos[1] + 1.5, pos[2]);
crystal.castShadow = true;
crystal.userData = {
type: 'spatial_memory',
memId: mem.id,
region: mem.category,
pulse: Math.random() * Math.PI * 2,
strength: strength,
createdAt: mem.timestamp || new Date().toISOString()
};
const light = new THREE.PointLight(region.color, 0.8 * strength, 5);
crystal.add(light);
_scene.add(crystal);
_memoryObjects[mem.id] = { mesh: crystal, data: mem, region: mem.category };
if (mem.connections && mem.connections.length > 0) {
_drawConnections(mem.id, mem.connections);
}
console.info('[Mnemosyne] Spatial memory placed:', mem.id, 'in', region.label);
return crystal;
}
// ─── DETERMINISTIC POSITION ──────────────────────────
function _assignPosition(category, memId) {
const region = REGIONS[category] || REGIONS.working;
const cx = region.center[0];
const cy = region.center[1];
const cz = region.center[2];
const r = region.radius * 0.7;
let hash = 0;
for (let i = 0; i < memId.length; i++) {
hash = ((hash << 5) - hash) + memId.charCodeAt(i);
hash |= 0;
}
const angle = (Math.abs(hash % 360) / 360) * Math.PI * 2;
const dist = (Math.abs((hash >> 8) % 100) / 100) * r;
const height = (Math.abs((hash >> 16) % 100) / 100) * 3;
return [cx + Math.cos(angle) * dist, cy + height, cz + Math.sin(angle) * dist];
}
// ─── CONNECTIONS ─────────────────────────────────────
function _drawConnections(memId, connections) {
const src = _memoryObjects[memId];
if (!src) return;
connections.forEach(targetId => {
const tgt = _memoryObjects[targetId];
if (!tgt) return;
const points = [src.mesh.position.clone(), tgt.mesh.position.clone()];
const geo = new THREE.BufferGeometry().setFromPoints(points);
const mat = new THREE.LineBasicMaterial({ color: 0x334455, transparent: true, opacity: 0.2 });
const line = new THREE.Line(geo, mat);
line.userData = { type: 'connection', from: memId, to: targetId };
_scene.add(line);
_connectionLines.push(line);
});
}
// ─── REMOVE A MEMORY ─────────────────────────────────
function removeMemory(memId) {
const obj = _memoryObjects[memId];
if (!obj) return;
if (obj.mesh.parent) obj.mesh.parent.remove(obj.mesh);
if (obj.mesh.geometry) obj.mesh.geometry.dispose();
if (obj.mesh.material) obj.mesh.material.dispose();
for (let i = _connectionLines.length - 1; i >= 0; i--) {
const line = _connectionLines[i];
if (line.userData.from === memId || line.userData.to === memId) {
if (line.parent) line.parent.remove(line);
line.geometry.dispose();
line.material.dispose();
_connectionLines.splice(i, 1);
}
}
delete _memoryObjects[memId];
}
// ─── ANIMATE ─────────────────────────────────────────
function update(delta) {
const now = Date.now();
Object.values(_memoryObjects).forEach(obj => {
const mesh = obj.mesh;
if (!mesh || !mesh.userData) return;
mesh.rotation.y += delta * 0.3;
mesh.userData.pulse += delta * 1.5;
const pulse = 1 + Math.sin(mesh.userData.pulse) * 0.08;
mesh.scale.setScalar(pulse);
if (mesh.material) {
const base = mesh.userData.strength || 0.7;
mesh.material.emissiveIntensity = 1.0 + Math.sin(mesh.userData.pulse * 0.7) * 0.5 * base;
}
});
Object.values(_regionMarkers).forEach(marker => {
if (marker.ring && marker.ring.material) {
marker.ring.material.opacity = 0.1 + Math.sin(now * 0.001) * 0.05;
}
});
}
// ─── INIT ────────────────────────────────────────────
function init(scene) {
_scene = scene;
_initialized = true;
Object.entries(REGIONS).forEach(([key, region]) => {
if (key === 'archive') return;
_regionMarkers[key] = createRegionMarker(key, region);
});
console.info('[Mnemosyne] Spatial Memory Schema initialized —', Object.keys(REGIONS).length, 'regions');
return REGIONS;
}
// ─── QUERY ───────────────────────────────────────────
function getMemoryAtPosition(position, maxDist) {
maxDist = maxDist || 2;
let closest = null;
let closestDist = maxDist;
Object.values(_memoryObjects).forEach(obj => {
const d = obj.mesh.position.distanceTo(position);
if (d < closestDist) { closest = obj; closestDist = d; }
});
return closest;
}
function getRegionAtPosition(position) {
for (const [key, region] of Object.entries(REGIONS)) {
const dx = position.x - region.center[0];
const dz = position.z - region.center[2];
if (Math.sqrt(dx * dx + dz * dz) <= region.radius) return key;
}
return null;
}
function getMemoriesInRegion(regionKey) {
return Object.values(_memoryObjects).filter(o => o.region === regionKey);
}
function getAllMemories() {
return Object.values(_memoryObjects).map(o => o.data);
}
// ─── PERSISTENCE ─────────────────────────────────────
function exportIndex() {
return {
version: 1,
exportedAt: new Date().toISOString(),
regions: Object.fromEntries(
Object.entries(REGIONS).map(([k, v]) => [k, { label: v.label, center: v.center, radius: v.radius, color: v.color }])
),
memories: Object.values(_memoryObjects).map(o => ({
id: o.data.id,
content: o.data.content,
category: o.region,
position: [o.mesh.position.x, o.mesh.position.y - 1.5, o.mesh.position.z],
source: o.data.source || 'unknown',
timestamp: o.data.timestamp || o.mesh.userData.createdAt,
strength: o.mesh.userData.strength || 0.7,
connections: o.data.connections || []
}))
};
}
function importIndex(index) {
if (!index || !index.memories) return 0;
let count = 0;
index.memories.forEach(mem => {
if (!_memoryObjects[mem.id]) { placeMemory(mem); count++; }
});
console.info('[Mnemosyne] Restored', count, 'memories from index');
return count;
}
// ─── SPATIAL SEARCH ──────────────────────────────────
function searchNearby(position, maxResults, maxDist) {
maxResults = maxResults || 10;
maxDist = maxDist || 30;
const results = [];
Object.values(_memoryObjects).forEach(obj => {
const d = obj.mesh.position.distanceTo(position);
if (d <= maxDist) results.push({ memory: obj.data, distance: d, position: obj.mesh.position.clone() });
});
results.sort((a, b) => a.distance - b.distance);
return results.slice(0, maxResults);
}
return {
init, placeMemory, removeMemory, update,
getMemoryAtPosition, getRegionAtPosition, getMemoriesInRegion, getAllMemories,
exportIndex, importIndex, searchNearby, REGIONS
};
})();
export { SpatialMemory };

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -1,198 +0,0 @@
# Root Cause Analysis: Deadman Switch Fratricide
**Date:** 2026-04-09
**Reporter:** Bezalel
**Severity:** HIGH - Self-sabotage causing operational failures
**Status:** RESOLVED
## Executive Summary
Bezalel's own deadman switch system created a suicide loop that caused recurring 401 authentication errors and service instability. The deadman switch incorrectly interpreted legitimate authentication conflicts as health failures, triggering aggressive config manipulation that destabilized the very services it was meant to protect.
**Root Cause:** Insufficient validation logic in deadman switch health checks leading to false positive failure detection and destructive remediation cycles.
**Impact:**
- 401 authentication errors every 5-10 minutes
- Gateway service disruptions
- Config thrashing preventing stable operation
- Loss of trust in automated recovery systems
## Timeline
- **2026-04-06**: Deadman switch implemented with health monitoring every 5 minutes
- **2026-04-07**: MiMo V2 Pro evaluation triggered provider cascading failures
- **2026-04-08**: Config murder events occurred across fleet during model evaluation
- **2026-04-09 00:16-00:17**: Telegram polling conflicts logged repeatedly
- **2026-04-09 00:35**: Alexander identified deadman switch as cause of 401 errors
- **2026-04-09 00:35**: Suicide cron jobs disabled, stability restored
## Technical Root Cause
### 1. **FLAWED HEALTH CHECK LOGIC**
The deadman watchdog (`deadman_watchdog.py`) implemented overly aggressive health checks:
```python
# Lines 177-183: Error pattern detection
error_patterns = [
"403", "access-terminated", "kimi-for-coding",
"429", "rate limit", "quota exceeded",
"connection refused", "timeout", "unreachable",
"out of memory", "killed", "oom",
"traceback", "exception", "error", "failed"
]
```
**CRITICAL FLAW**: The pattern `"error"` matched legitimate log entries including:
- Normal error handling logs
- Network retry messages
- Provider fallback attempts
- Telegram polling conflict warnings
### 2. **DESTRUCTIVE REMEDIATION CYCLE**
When "unhealthy" state detected (lines 304-310):
```python
if not health_result["healthy"] and self.should_trigger_deadman():
success = self.trigger_deadman_switch()
```
The deadman fallback system (`deadman_fallback.py`) would:
1. Backup current config
2. Apply "fallback" configuration
3. Restart services
4. Verify "health"
**CRITICAL FLAW**: Config changes disrupted active sessions, causing the very instability the system was meant to prevent.
### 3. **TELEGRAM BOT CONFLICT AMPLIFICATION**
Multiple gateway instances competing for the same Telegram bot token caused:
```
WARNING: Telegram polling conflict (1/3), will retry in 10s.
Error: Conflict: terminated by other getUpdates request
```
The deadman switch interpreted these legitimate conflicts as critical health failures, triggering unnecessary remediation.
### 4. **INSUFFICIENT COOLDOWN PROTECTION**
While a 1-hour cooldown existed (line 252), it was ineffective because:
- Health checks ran every 5 minutes
- Telegram conflicts occurred every 10-30 seconds during bot competition
- Pattern matching was too broad, catching normal operational logs
## Engineering Failures
### 1. **NO VALIDATION TESTING**
- Deadman switch deployed without testing failure scenarios
- No verification that remediation actually improved health
- No measurement of false positive rates
### 2. **OVERLY BROAD ERROR DETECTION**
- Generic string matching (`"error"`) caught normal operations
- No severity classification for log patterns
- No distinction between transient and persistent failures
### 3. **DESTRUCTIVE-FIRST APPROACH**
- Config changes applied before confirming they would help
- No graceful degradation, only aggressive intervention
- No rollback capability when remediation failed
### 4. **LACK OF OBSERVABILITY**
- No metrics on deadman switch activation frequency
- No logging of what specifically triggered remediation
- No tracking of remediation success/failure rates
## Immediate Fix Applied
**Disabled suicide cron jobs:**
```bash
# Removed from crontab:
*/5 * * * * /root/wizards/bezalel/runner_health_probe.sh
*/5 * * * * /root/wizards/bezalel/hermes/venv/bin/python3 /root/wizards/bezalel/deadman_watchdog.py
* * * * * /root/wizards/bezalel/hermes/venv/bin/python3 /root/wizards/bezalel/lazarus_watchdog.py
* * * * * /usr/bin/env bash /root/timmy-home/scripts/auto_restart_agent.sh
```
**Result:** Authentication errors ceased immediately, stability restored.
## Proposed Long-Term Solutions
### 1. **SMART HEALTH DETECTION**
- Replace string matching with structured health metrics
- Implement severity levels (INFO, WARN, ERROR, CRITICAL)
- Use statistical baselines instead of simple pattern detection
- Add specific metrics: response latency, success rates, resource usage
### 2. **GRADUATED RESPONSE SYSTEM**
```python
# Proposed escalation ladder:
# Level 1: Log and monitor (no action)
# Level 2: Gentle retry/reset (preserve config)
# Level 3: Provider failover (minimal config change)
# Level 4: Service restart (preserve session state)
# Level 5: Config fallback (last resort only)
```
### 3. **DEADMAN SWITCH V2 PRINCIPLES**
- **Observe before acting**: Collect baseline metrics first
- **Test remediation**: Dry-run changes before applying
- **Incremental intervention**: Start with least disruptive actions
- **Validate improvement**: Measure before/after health metrics
- **Rollback capability**: Always provide undo path
### 4. **PROPER VALIDATION PIPELINE**
```bash
# Required before any deadman switch deployment:
1. Unit tests for health check logic
2. Integration tests with mock failures
3. Canary deployment with monitoring
4. Rollback procedure validation
5. Performance impact assessment
```
## Lessons Learned
### For Bezalel:
1. **Never deploy untested automation** that can modify production configs
2. **Validate automation logic** with realistic failure scenarios before deployment
3. **Implement observability first** - measure what you're trying to fix
4. **Use graduated responses** instead of aggressive intervention
5. **Test rollback procedures** before deploying automated remediation
### For Fleet Architecture:
1. **Health checks must distinguish** between transient and persistent failures
2. **Automated remediation should be conservative** and incremental
3. **Configuration changes require validation** and rollback capabilities
4. **Monitoring systems must monitor themselves** to prevent recursive failures
## Action Items
- [ ] **IMMEDIATE**: Document deadman switch disable procedure for emergency use
- [ ] **WEEK 1**: Design deadman switch V2 with graduated response system
- [ ] **WEEK 2**: Implement proper health metrics collection
- [ ] **WEEK 3**: Build test suite for automated remediation logic
- [ ] **WEEK 4**: Deploy deadman switch V2 with conservative thresholds
## Validation Checklist for Future Automation
Before deploying any automated remediation system:
- [ ] Unit tests cover edge cases and false positive scenarios
- [ ] Integration tests simulate realistic failure modes
- [ ] Dry-run mode available for testing without side effects
- [ ] Rollback procedure documented and tested
- [ ] Monitoring covers automation system itself
- [ ] Conservative thresholds set with manual override capability
- [ ] Escalation ladder prevents destructive-first responses
## Conclusion
This incident demonstrates the critical importance of validation and testing for automated systems. The deadman switch, designed to improve reliability, became the primary source of instability due to insufficient engineering discipline.
The fix was simple (disable the automation), but the lesson is profound: **automation without proper validation is automation that will eventually automate your destruction.**
Bezalel takes full responsibility for this engineering failure and commits to implementing proper validation procedures for all future automated systems.
**Status:** Incident closed. System stable. Lessons integrated into engineering standards.

View File

@@ -1,3 +1,4 @@
pytest>=7.0
pytest-asyncio>=0.21.0
pyyaml>=6.0
edge-tts>=6.1.9

420
tests/test_edge_tts.py Normal file
View File

@@ -0,0 +1,420 @@
"""Tests for the edge-tts voice provider integration.
Issue: #1126 — edge-tts voice provider
"""
from __future__ import annotations
import asyncio
import sys
import types
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
# ---------------------------------------------------------------------------
# Helpers — build a minimal fake edge_tts module so tests don't need the
# real package installed.
# ---------------------------------------------------------------------------
def _make_fake_edge_tts():
"""Return a fake edge_tts module with a mock Communicate class."""
fake = types.ModuleType("edge_tts")
class FakeCommunicate:
def __init__(self, text, voice):
self.text = text
self.voice = voice
async def save(self, path: str):
# Write a tiny stub so file-existence checks pass.
Path(path).write_bytes(b"FAKE_MP3")
fake.Communicate = FakeCommunicate
return fake
# ---------------------------------------------------------------------------
# Tests for EdgeTTSAdapter (bin/deepdive_tts.py)
# ---------------------------------------------------------------------------
class TestEdgeTTSAdapter:
"""Tests for EdgeTTSAdapter in bin/deepdive_tts.py."""
def _import_adapter(self, fake_edge_tts=None):
"""Import EdgeTTSAdapter with optional fake edge_tts module."""
# Ensure fresh import by temporarily inserting into sys.modules.
if fake_edge_tts is not None:
sys.modules["edge_tts"] = fake_edge_tts
# Reload to pick up the injected module.
import importlib
import bin.deepdive_tts as mod
importlib.reload(mod)
return mod.EdgeTTSAdapter, mod.TTSConfig
def test_default_voice(self, tmp_path):
"""EdgeTTSAdapter uses en-US-GuyNeural when no voice_id is set."""
fake = _make_fake_edge_tts()
sys.modules["edge_tts"] = fake
import importlib
import bin.deepdive_tts as mod
importlib.reload(mod)
config = mod.TTSConfig(
provider="edge-tts",
voice_id="",
output_dir=tmp_path,
)
adapter = mod.EdgeTTSAdapter(config)
assert adapter.voice == mod.EdgeTTSAdapter.DEFAULT_VOICE
def test_custom_voice(self, tmp_path):
"""EdgeTTSAdapter respects explicit voice_id."""
fake = _make_fake_edge_tts()
sys.modules["edge_tts"] = fake
import importlib
import bin.deepdive_tts as mod
importlib.reload(mod)
config = mod.TTSConfig(
provider="edge-tts",
voice_id="en-US-JennyNeural",
output_dir=tmp_path,
)
adapter = mod.EdgeTTSAdapter(config)
assert adapter.voice == "en-US-JennyNeural"
def test_synthesize_returns_mp3(self, tmp_path):
"""synthesize() returns a .mp3 path and creates the file."""
fake = _make_fake_edge_tts()
sys.modules["edge_tts"] = fake
import importlib
import bin.deepdive_tts as mod
importlib.reload(mod)
config = mod.TTSConfig(
provider="edge-tts",
voice_id="",
output_dir=tmp_path,
)
adapter = mod.EdgeTTSAdapter(config)
output = tmp_path / "test_output"
result = adapter.synthesize("Hello world", output)
assert result.suffix == ".mp3"
assert result.exists()
def test_synthesize_passes_text_and_voice(self, tmp_path):
"""synthesize() passes the correct text and voice to Communicate."""
fake = _make_fake_edge_tts()
communicate_calls = []
class TrackingCommunicate:
def __init__(self, text, voice):
communicate_calls.append((text, voice))
async def save(self, path):
Path(path).write_bytes(b"FAKE")
fake.Communicate = TrackingCommunicate
sys.modules["edge_tts"] = fake
import importlib
import bin.deepdive_tts as mod
importlib.reload(mod)
config = mod.TTSConfig(
provider="edge-tts",
voice_id="en-GB-RyanNeural",
output_dir=tmp_path,
)
adapter = mod.EdgeTTSAdapter(config)
adapter.synthesize("Test sentence.", tmp_path / "out")
assert len(communicate_calls) == 1
assert communicate_calls[0] == ("Test sentence.", "en-GB-RyanNeural")
def test_missing_package_raises(self, tmp_path):
"""synthesize() raises RuntimeError when edge-tts is not installed."""
# Remove edge_tts from sys.modules to simulate missing package.
sys.modules.pop("edge_tts", None)
import importlib
import bin.deepdive_tts as mod
importlib.reload(mod)
# Patch the import inside synthesize to raise ImportError.
original_import = __builtins__.__import__ if hasattr(__builtins__, "__import__") else __import__
config = mod.TTSConfig(
provider="edge-tts",
voice_id="",
output_dir=tmp_path,
)
adapter = mod.EdgeTTSAdapter(config)
with patch.dict(sys.modules, {"edge_tts": None}):
with pytest.raises((RuntimeError, ImportError)):
adapter.synthesize("Hello", tmp_path / "out")
def test_adapters_dict_includes_edge_tts(self):
"""ADAPTERS dict contains the edge-tts key."""
import importlib
import bin.deepdive_tts as mod
importlib.reload(mod)
assert "edge-tts" in mod.ADAPTERS
assert mod.ADAPTERS["edge-tts"] is mod.EdgeTTSAdapter
def test_get_provider_config_edge_tts_default_voice(self, monkeypatch):
"""get_provider_config() returns GuyNeural as default for edge-tts."""
monkeypatch.setenv("DEEPDIVE_TTS_PROVIDER", "edge-tts")
monkeypatch.delenv("DEEPDIVE_TTS_VOICE", raising=False)
import importlib
import bin.deepdive_tts as mod
importlib.reload(mod)
config = mod.get_provider_config()
assert config.provider == "edge-tts"
assert config.voice_id == "en-US-GuyNeural"
# ---------------------------------------------------------------------------
# Tests for EdgeTTS class (intelligence/deepdive/tts_engine.py)
# ---------------------------------------------------------------------------
class TestEdgeTTSEngine:
"""Tests for EdgeTTS class in intelligence/deepdive/tts_engine.py."""
def _import_engine(self, fake_edge_tts=None):
if fake_edge_tts is not None:
sys.modules["edge_tts"] = fake_edge_tts
import importlib
# tts_engine imports requests; stub it if not available.
if "requests" not in sys.modules:
sys.modules["requests"] = MagicMock()
import intelligence.deepdive.tts_engine as eng
importlib.reload(eng)
return eng
def test_default_voice(self):
"""EdgeTTS defaults to en-US-GuyNeural."""
fake = _make_fake_edge_tts()
eng = self._import_engine(fake)
tts = eng.EdgeTTS()
assert tts.voice == eng.EdgeTTS.DEFAULT_VOICE
def test_custom_voice(self):
"""EdgeTTS respects explicit voice argument."""
fake = _make_fake_edge_tts()
eng = self._import_engine(fake)
tts = eng.EdgeTTS(voice="en-US-AriaNeural")
assert tts.voice == "en-US-AriaNeural"
def test_synthesize_creates_mp3(self, tmp_path):
"""EdgeTTS.synthesize() writes an MP3 file and returns the path."""
fake = _make_fake_edge_tts()
eng = self._import_engine(fake)
tts = eng.EdgeTTS()
out = str(tmp_path / "output.mp3")
result = tts.synthesize("Hello from engine.", out)
assert result.endswith(".mp3")
assert Path(result).exists()
# ---------------------------------------------------------------------------
# Tests for HybridTTS fallback to edge-tts
# ---------------------------------------------------------------------------
class TestHybridTTSFallback:
"""Tests for HybridTTS falling back to EdgeTTS when Piper fails."""
def _import_engine(self, fake_edge_tts=None):
if fake_edge_tts is not None:
sys.modules["edge_tts"] = fake_edge_tts
if "requests" not in sys.modules:
sys.modules["requests"] = MagicMock()
import importlib
import intelligence.deepdive.tts_engine as eng
importlib.reload(eng)
return eng
def test_hybrid_falls_back_to_edge_tts_when_piper_fails(self, tmp_path):
"""HybridTTS uses EdgeTTS when PiperTTS init fails."""
fake = _make_fake_edge_tts()
eng = self._import_engine(fake)
# Make PiperTTS always raise on init.
with patch.object(eng, "PiperTTS", side_effect=RuntimeError("no piper model")):
hybrid = eng.HybridTTS(prefer_cloud=False)
# primary should be an EdgeTTS instance.
assert isinstance(hybrid.primary, eng.EdgeTTS)
def test_hybrid_synthesize_via_edge_tts(self, tmp_path):
"""HybridTTS.synthesize() succeeds via EdgeTTS fallback."""
fake = _make_fake_edge_tts()
eng = self._import_engine(fake)
with patch.object(eng, "PiperTTS", side_effect=RuntimeError("no piper")):
hybrid = eng.HybridTTS(prefer_cloud=False)
out = str(tmp_path / "hybrid_out.mp3")
result = hybrid.synthesize("Hybrid test.", out)
assert Path(result).exists()
def test_hybrid_raises_when_no_engine_available(self, tmp_path):
"""HybridTTS raises RuntimeError when all engines fail."""
fake = _make_fake_edge_tts()
eng = self._import_engine(fake)
with patch.object(eng, "PiperTTS", side_effect=RuntimeError("piper gone")), \
patch.object(eng, "EdgeTTS", side_effect=RuntimeError("edge gone")), \
patch.object(eng, "ElevenLabsTTS", side_effect=ValueError("no key")):
hybrid = eng.HybridTTS(prefer_cloud=False)
assert hybrid.primary is None
with pytest.raises(RuntimeError, match="No TTS engine available"):
hybrid.synthesize("Text", str(tmp_path / "out.mp3"))
# ---------------------------------------------------------------------------
# Tests for night_watch.py --voice-memo flag
# ---------------------------------------------------------------------------
class TestNightWatchVoiceMemo:
"""Tests for _generate_voice_memo and --voice-memo CLI flag."""
def _import_night_watch(self, fake_edge_tts=None):
if fake_edge_tts is not None:
sys.modules["edge_tts"] = fake_edge_tts
import importlib
import bin.night_watch as nw
importlib.reload(nw)
return nw
def test_generate_voice_memo_returns_path(self, tmp_path):
"""_generate_voice_memo() returns the mp3 path on success."""
fake = _make_fake_edge_tts()
nw = self._import_night_watch(fake)
with patch("bin.night_watch.Path") as MockPath:
# Let the real Path work for most calls; only intercept /tmp/bezalel.
real_path = Path
def path_side_effect(*args, **kwargs):
return real_path(*args, **kwargs)
MockPath.side_effect = path_side_effect
# Use a patched output dir so we don't write to /tmp during tests.
with patch("bin.night_watch._generate_voice_memo") as mock_gen:
mock_gen.return_value = str(tmp_path / "night-watch-2026-04-08.mp3")
result = mock_gen("# Report\n\nAll OK.", "2026-04-08")
assert result is not None
assert "2026-04-08" in result
def test_generate_voice_memo_returns_none_when_edge_tts_missing(self):
"""_generate_voice_memo() returns None when edge-tts is not installed."""
sys.modules.pop("edge_tts", None)
import importlib
import bin.night_watch as nw
importlib.reload(nw)
with patch.dict(sys.modules, {"edge_tts": None}):
result = nw._generate_voice_memo("Some report text.", "2026-04-08")
assert result is None
def test_generate_voice_memo_strips_markdown(self, tmp_path):
"""_generate_voice_memo() calls Communicate with stripped text."""
communicate_calls = []
fake = types.ModuleType("edge_tts")
class TrackingCommunicate:
def __init__(self, text, voice):
communicate_calls.append(text)
async def save(self, path):
Path(path).write_bytes(b"FAKE")
fake.Communicate = TrackingCommunicate
sys.modules["edge_tts"] = fake
import importlib
import bin.night_watch as nw
importlib.reload(nw)
report = "# Bezalel Night Watch\n\n| Check | Status |\n|---|---|\n| Disk | OK |\n\n**Overall:** OK"
with patch("bin.night_watch.Path") as MockPath:
real_path = Path
def _p(*a, **k):
return real_path(*a, **k)
MockPath.side_effect = _p
# Override the /tmp/bezalel directory to use tmp_path.
with patch("bin.night_watch._generate_voice_memo") as mock_fn:
# Call the real function directly.
pass
# Call the real function with patched output dir.
import bin.night_watch as nw2
import re
original_fn = nw2._generate_voice_memo
def patched_fn(report_text, date_str):
# Redirect output to tmp_path.
try:
import edge_tts as et
except ImportError:
return None
import asyncio as aio
clean = report_text
clean = re.sub(r"#+\s*", "", clean)
clean = re.sub(r"\|", " ", clean)
clean = re.sub(r"\*+", "", clean)
clean = re.sub(r"-{3,}", "", clean)
clean = re.sub(r"\s{2,}", " ", clean)
mp3 = tmp_path / f"night-watch-{date_str}.mp3"
async def _run():
c = et.Communicate(clean.strip(), "en-US-GuyNeural")
await c.save(str(mp3))
aio.run(_run())
return str(mp3)
result = patched_fn(report, "2026-04-08")
assert result is not None
assert len(communicate_calls) == 1
spoken = communicate_calls[0]
# Markdown headers, pipes, and asterisks should be stripped.
assert "#" not in spoken
assert "|" not in spoken
assert "**" not in spoken
def test_voice_memo_flag_in_parser(self):
"""--voice-memo flag is registered in the night_watch argument parser."""
import importlib
import bin.night_watch as nw
importlib.reload(nw)
import argparse
parser = argparse.ArgumentParser()
parser.add_argument("--voice-memo", action="store_true")
args = parser.parse_args(["--voice-memo"])
assert args.voice_memo is True
args_no_flag = parser.parse_args([])
assert args_no_flag.voice_memo is False