diff --git a/Makefile b/Makefile
index aae9adf..8084763 100644
--- a/Makefile
+++ b/Makefile
@@ -206,7 +206,6 @@ setup: _preflight-compose ## Interactive first-run wizard (re-runnable; remember
}; \
render .config.yaml.template data/.config.yaml; \
render docker-compose.yml.template docker-compose.yml; \
- render zeroclaw-bridge.service.template zeroclaw-bridge.service; \
echo ""; \
$(MAKE) fetch-models; \
echo ""; \
@@ -219,8 +218,7 @@ setup: _preflight-compose ## Interactive first-run wizard (re-runnable; remember
echo " 1. Flash the StackChan firmware (see SETUP.md or m5stack/StackChan repo)."; \
echo " 2. In the device's Advanced Options, set the OTA URL to:"; \
echo " http://$$XIAOZHI_HOST:8003/xiaozhi/ota/"; \
- echo " 3. Deploy zeroclaw-bridge.service to the ZeroClaw host and start it."; \
- echo " 4. Run 'make doctor' to verify everything is healthy."; \
+ echo " 3. Run 'make doctor' to verify everything is healthy."; \
echo ""
# ─────────────────────────────────────────────────────────────────────
diff --git a/bridge/household.py b/bridge/household.py
deleted file mode 100644
index f1065a8..0000000
--- a/bridge/household.py
+++ /dev/null
@@ -1,404 +0,0 @@
-"""Household registry — the source of truth for "who lives here."
-
-Loaded from a single YAML file (default `~/.zeroclaw/household.yaml`,
-overridable via `HOUSEHOLD_YAML_PATH`). The registry powers identity-
-aware behaviour across the bridge:
-
- - the speaker resolver (self-ID phrases, time-of-day priors, calendar
- prefix mapping)
- - the proactive greeter (display name, personality, birthday)
- - the `[Speaking with]` block injected into voice-channel prompts
-
-Schema is small and human-edited. Free-text fields (`personality`,
-`appearance`, `voice`, `family_context`, `notes`) flow through to the
-LLM verbatim. Structured fields (`birthdate`, `calendar_prefix`,
-`self_id_phrases`, `usual_times`) are consumed by code.
-
-Reload semantics: the registry stat-checks the YAML file on every
-public access. If mtime has moved, it re-parses. Cheap; lets you edit
-household.yaml on the running ZeroClaw host without a bridge restart.
-
-If PyYAML is missing or the file is unreadable/malformed, the registry
-starts empty rather than crashing the bridge. An empty registry is a
-valid state — it means everyone resolves to `_household`, exactly like
-today.
-"""
-from __future__ import annotations
-
-import logging
-import os
-import re
-from dataclasses import dataclass, field
-from datetime import date, datetime
-from pathlib import Path
-from typing import Any, Iterable, Optional
-
-log = logging.getLogger("zeroclaw-bridge.household")
-
-try:
- import yaml # type: ignore
- _YAML_AVAILABLE = True
-except ImportError: # pragma: no cover — install-time gate
- yaml = None # type: ignore
- _YAML_AVAILABLE = False
- log.warning(
- "PyYAML not installed — household registry will start empty. "
- "Add `PyYAML>=6.0,<7` to bridge/requirements.txt and reinstall."
- )
-
-DEFAULT_HOUSEHOLD_PATH = "~/.zeroclaw/household.yaml"
-DEFAULT_PERSON_FALLBACK = "_household"
-
-_LEADING_PUNCT_RE = re.compile(r"^[\s\W_]+")
-
-
-@dataclass(frozen=True)
-class Person:
- """One household member. All fields optional except `id` and
- `display_name` — a YAML entry with just those two is valid."""
-
- id: str
- display_name: str
- relation: Optional[str] = None
- pronouns: Optional[str] = None
- age: Optional[int] = None
- birthdate: Optional[date] = None
- appearance: Optional[str] = None
- voice: Optional[str] = None
- personality: Optional[str] = None
- interests: tuple[str, ...] = ()
- family_context: Optional[str] = None
- notes: Optional[str] = None
- do_not: tuple[str, ...] = ()
- self_id_phrases: tuple[str, ...] = ()
- usual_times: dict[str, tuple[str, ...]] = field(default_factory=dict)
- calendar_prefix: Optional[str] = None
- voice_print_id: Optional[str] = None
-
- def days_until_birthday(self, *, today: Optional[date] = None) -> Optional[int]:
- if self.birthdate is None:
- return None
- ref = today or date.today()
- try:
- this_year = self.birthdate.replace(year=ref.year)
- except ValueError:
- this_year = date(ref.year, self.birthdate.month, 28)
- if this_year >= ref:
- return (this_year - ref).days
- try:
- next_year = self.birthdate.replace(year=ref.year + 1)
- except ValueError:
- next_year = date(ref.year + 1, self.birthdate.month, 28)
- return (next_year - ref).days
-
- def compact_description(self, *, max_chars: int = 200) -> str:
- """A single-line summary suitable for a `[Speaking with]` block.
- Combines display name, age (if known), and the most identifying
- free-text bits. Never includes raw birthdate (PII funnel)."""
- parts: list[str] = [self.display_name]
- meta: list[str] = []
- if self.age is not None:
- meta.append(f"{self.age}yo")
- elif self.relation:
- meta.append(self.relation)
- if self.personality:
- meta.append(self.personality)
- if self.interests:
- meta.append("loves " + ", ".join(self.interests[:3]))
- if meta:
- parts.append(" — " + "; ".join(meta))
- out = "".join(parts).strip()
- if len(out) > max_chars:
- out = out[: max_chars - 1].rstrip() + "…"
- return out
-
-
-class HouseholdRegistry:
- """YAML-backed registry of household members with hot-reload."""
-
- def __init__(
- self,
- path: Optional[str | Path] = None,
- *,
- clock: Any = None,
- ) -> None:
- raw = (
- os.fspath(path) if path is not None
- else os.environ.get("HOUSEHOLD_YAML_PATH", DEFAULT_HOUSEHOLD_PATH)
- )
- self._path = Path(raw).expanduser()
- self._clock = clock
- self._mtime: float = 0.0
- self._people: dict[str, Person] = {}
- self._default_person: str = DEFAULT_PERSON_FALLBACK
- self._by_prefix: dict[str, str] = {}
- self._self_id: list[tuple[str, str]] = []
- self._reload()
-
- @property
- def path(self) -> Path:
- return self._path
-
- @property
- def default_person(self) -> str:
- self._reload_if_changed()
- return self._default_person
-
- def get(self, person_id: str) -> Optional[Person]:
- self._reload_if_changed()
- if not person_id:
- return None
- return self._people.get(person_id.lower())
-
- def iter(self) -> Iterable[Person]: # noqa: A003
- self._reload_if_changed()
- return tuple(self._people.values())
-
- def render_roster_for_vlm(self, *, max_line_chars: int = 80) -> str:
- """Render members with a non-empty `appearance:` as one line per
- person, suitable for inlining into a VLM identification prompt.
- Lines are sorted by display_name for stable diffing across
- reloads. Empty appearance is treated as "exclude from roster" —
- a member with no visual description cannot be identified by
- photo, so injecting their name into the prompt would only
- invite the VLM to false-positive on them."""
- self._reload_if_changed()
- lines: list[str] = []
- for p in sorted(self._people.values(), key=lambda x: x.display_name.lower()):
- appearance = (p.appearance or "").strip()
- if not appearance:
- continue
- if len(appearance) > max_line_chars:
- appearance = appearance[: max_line_chars - 3].rstrip() + "..."
- lines.append(f" {p.display_name}: {appearance}")
- return "\n".join(lines)
-
- def roster_ids_with_appearance(self) -> set[str]:
- """Set of canonical person_ids that have a non-empty appearance.
- Used by the VLM-response parser to validate that the name the
- VLM returned is one of the members it was asked to choose from."""
- self._reload_if_changed()
- return {
- p.id for p in self._people.values()
- if (p.appearance or "").strip()
- }
-
- def get_by_calendar_prefix(self, prefix: str) -> Optional[Person]:
- """Look up a person by their `[Name]` calendar prefix.
- Case-insensitive; brackets optional."""
- self._reload_if_changed()
- if not prefix:
- return None
- key = prefix.strip().lower()
- if not key.startswith("["):
- key = f"[{key}]"
- person_id = self._by_prefix.get(key)
- return self._people.get(person_id) if person_id else None
-
- def match_self_id(self, text: str) -> Optional[Person]:
- """Match an utterance against every person's `self_id_phrases`.
- Phrases are matched at the leading position only, after stripping
- leading punctuation/whitespace. Case-insensitive. Returns the
- first matching Person, or None."""
- self._reload_if_changed()
- if not text or not self._self_id:
- return None
- normalised = _LEADING_PUNCT_RE.sub("", text).lower()
- for phrase, person_id in self._self_id:
- if normalised.startswith(phrase):
- tail_pos = len(phrase)
- if tail_pos >= len(normalised):
- return self._people.get(person_id)
- tail = normalised[tail_pos]
- if not tail.isalnum() and tail != "_":
- return self._people.get(person_id)
- return None
-
- def reload(self) -> None:
- """Force reload regardless of mtime. Mostly for tests."""
- self._mtime = 0.0
- self._reload()
-
- def _reload_if_changed(self) -> None:
- try:
- stat = self._path.stat()
- except OSError:
- return
- if stat.st_mtime != self._mtime:
- self._reload()
-
- def _reload(self) -> None:
- people: dict[str, Person] = {}
- by_prefix: dict[str, str] = {}
- self_id: list[tuple[str, str]] = []
- default_person = DEFAULT_PERSON_FALLBACK
-
- if not _YAML_AVAILABLE or yaml is None:
- self._commit(people, by_prefix, self_id, default_person, mtime=0.0)
- return
- try:
- stat = self._path.stat()
- except OSError:
- log.info(
- "household: %s not found — registry empty (resolves to %s)",
- self._path, default_person,
- )
- self._commit(people, by_prefix, self_id, default_person, mtime=0.0)
- return
-
- try:
- raw = self._path.read_text(encoding="utf-8")
- data = yaml.safe_load(raw) or {}
- except (OSError, yaml.YAMLError):
- log.warning(
- "household: %s unreadable/malformed — registry empty",
- self._path, exc_info=True,
- )
- self._commit(
- people, by_prefix, self_id, default_person, mtime=stat.st_mtime,
- )
- return
-
- if not isinstance(data, dict):
- log.warning("household: top-level YAML is not a mapping; ignored")
- self._commit(
- people, by_prefix, self_id, default_person, mtime=stat.st_mtime,
- )
- return
-
- default_person = str(
- data.get("default_person") or DEFAULT_PERSON_FALLBACK
- )
- people_block = data.get("people") or {}
- if not isinstance(people_block, dict):
- log.warning("household: `people:` is not a mapping; ignored")
- people_block = {}
-
- for raw_id, entry in people_block.items():
- try:
- person = self._parse_person(str(raw_id), entry)
- except Exception:
- log.warning(
- "household: skipped malformed entry %r", raw_id,
- exc_info=True,
- )
- continue
- if person is None:
- continue
- people[person.id] = person
- if person.calendar_prefix:
- by_prefix[person.calendar_prefix.strip().lower()] = person.id
- for phrase in person.self_id_phrases:
- norm = phrase.strip().lower()
- if norm:
- self_id.append((norm, person.id))
- self_id.sort(key=lambda pair: len(pair[0]), reverse=True)
-
- self._commit(
- people, by_prefix, self_id, default_person, mtime=stat.st_mtime,
- )
- log.info(
- "household: loaded %d people from %s (default=%s)",
- len(people), self._path, default_person,
- )
-
- def _commit(
- self,
- people: dict[str, Person],
- by_prefix: dict[str, str],
- self_id: list[tuple[str, str]],
- default_person: str,
- *,
- mtime: float,
- ) -> None:
- self._people = people
- self._by_prefix = by_prefix
- self._self_id = self_id
- self._default_person = default_person
- self._mtime = mtime
-
- @staticmethod
- def _parse_person(raw_id: str, entry: Any) -> Optional[Person]:
- if not isinstance(entry, dict):
- return None
- person_id = raw_id.strip().lower()
- if not person_id or person_id == DEFAULT_PERSON_FALLBACK:
- log.warning("household: skipping reserved id %r", raw_id)
- return None
- display_name = str(
- entry.get("display_name") or raw_id
- ).strip() or raw_id
-
- birthdate_raw = entry.get("birthdate")
- birthdate: Optional[date] = None
- if birthdate_raw is not None:
- if isinstance(birthdate_raw, date):
- birthdate = birthdate_raw
- elif isinstance(birthdate_raw, datetime):
- birthdate = birthdate_raw.date()
- else:
- try:
- birthdate = date.fromisoformat(str(birthdate_raw))
- except ValueError:
- log.warning(
- "household: %s has unparseable birthdate %r",
- person_id, birthdate_raw,
- )
-
- usual_times_raw = entry.get("usual_times") or {}
- usual_times: dict[str, tuple[str, ...]] = {}
- if isinstance(usual_times_raw, dict):
- for key, val in usual_times_raw.items():
- if isinstance(val, (list, tuple)):
- usual_times[str(key)] = tuple(str(v) for v in val)
- elif isinstance(val, str):
- usual_times[str(key)] = (val,)
-
- return Person(
- id=person_id,
- display_name=display_name,
- relation=_opt_str(entry.get("relation")),
- pronouns=_opt_str(entry.get("pronouns")),
- age=_opt_int(entry.get("age")),
- birthdate=birthdate,
- appearance=_opt_str(entry.get("appearance")),
- voice=_opt_str(entry.get("voice")),
- personality=_opt_str(entry.get("personality")),
- interests=_to_str_tuple(entry.get("interests")),
- family_context=_opt_str(entry.get("family_context")),
- notes=_opt_str(entry.get("notes")),
- do_not=_to_str_tuple(entry.get("do_not")),
- self_id_phrases=_to_str_tuple(entry.get("self_id_phrases")),
- usual_times=usual_times,
- calendar_prefix=_opt_str(entry.get("calendar_prefix")),
- voice_print_id=_opt_str(entry.get("voice_print_id")),
- )
-
-
-def _opt_str(v: Any) -> Optional[str]:
- if v is None:
- return None
- s = str(v).strip()
- return s or None
-
-
-def _opt_int(v: Any) -> Optional[int]:
- if v is None or v == "":
- return None
- try:
- return int(v)
- except (TypeError, ValueError):
- return None
-
-
-def _to_str_tuple(v: Any) -> tuple[str, ...]:
- if v is None:
- return ()
- if isinstance(v, str):
- return tuple(s.strip() for s in v.split(",") if s.strip())
- if isinstance(v, (list, tuple)):
- return tuple(str(x).strip() for x in v if str(x).strip())
- return ()
-
-
-__all__ = ["HouseholdRegistry", "Person", "DEFAULT_PERSON_FALLBACK"]
diff --git a/bridge/perception/__init__.py b/bridge/perception/__init__.py
deleted file mode 100644
index f830b0a..0000000
--- a/bridge/perception/__init__.py
+++ /dev/null
@@ -1,9 +0,0 @@
-"""Perception package — read-only façade over the bridge's per-device
-caches. The bridge.py module owns the underlying dicts; modules here
-compose them into shapes consumed by the dashboard and the talk-turn
-prompt builder.
-"""
-
-from .cache import PerceptionSnapshot, snapshot
-
-__all__ = ["PerceptionSnapshot", "snapshot"]
diff --git a/bridge/perception/cache.py b/bridge/perception/cache.py
deleted file mode 100644
index 84a252d..0000000
--- a/bridge/perception/cache.py
+++ /dev/null
@@ -1,217 +0,0 @@
-"""Read-only snapshot of Dotty's current perception.
-
-Composes four independent per-device caches owned by bridge.py:
-
- * `_perception_state[device_id]` — face presence, identity, listening, mode
- * `_vision_cache[device_id]` — last VLM photo description (`wall_ts`)
- * `_audio_cache[device_id]` — last audio caption (`wall_ts`)
- * `_scene_synthesis_cache[device_id]` — last composed sentence (`ts_wall`)
-
-into a single `PerceptionSnapshot` consumed by:
-
- * `_build_perception_block()` in bridge.py — talk-turn system-prompt addendum
- * future dashboard refactor (out of scope for this commit)
-
-The snapshot is computed at read time; it does not subscribe to events
-or hold state of its own. All inputs are passed in explicitly so unit
-tests can construct them without monkey-patching the bridge module.
-
-Age gates match the caches' own TTLs (see VISION_CACHE_TTL_SEC,
-AUDIO_CACHE_TTL_SEC in bridge.py). Stale fields fall out of the
-snapshot rather than appearing as "(2 hours ago)" footnotes — the
-prompt block is for *current* perception only.
-"""
-
-from __future__ import annotations
-
-import time
-from dataclasses import dataclass
-from typing import Literal
-
-
-VISION_AGE_GATE_SEC = 60.0
-AUDIO_AGE_GATE_SEC = 120.0
-SCENE_SYNTH_AGE_GATE_SEC = 600.0
-# Identification is "fresh" for this many seconds after the last
-# face_recognized event. Spans the natural HuMan-detector flicker so a
-# brief drop-out doesn't collapse the snapshot from "identified" to
-# "detected" or "off". Mirrors FACE_IDENTITY_TTL_SEC in bridge.py.
-FACE_IDENTITY_AGE_GATE_SEC = 30.0
-
-
-FaceState = Literal["off", "detected", "identified"]
-
-
-STORY_FRAMING_LINE = (
- "You are inside the story you're telling — not narrating from outside. "
- "Describe what you see, hear, and feel as a character in the story. "
- "Use rich sensory language. Save vivid moments to memory when they happen."
-)
-
-
-@dataclass(frozen=True)
-class PerceptionSnapshot:
- state: str
- face: FaceState
- face_id: str | None
- face_mood: str | None
- listening: bool
- last_vision_desc: str | None
- last_vision_age_s: float | None
- last_audio_desc: str | None
- last_audio_age_s: float | None
- scene_synth: str | None
- scene_synth_age_s: float | None
-
- def to_prompt_block(self) -> str:
- """Format as a `[Current perception]` system-prompt addendum.
-
- Returns "" when nothing meaningful is cached *and* the current
- state has no special framing to add (idle/talk with empty
- caches). Story mode always emits at least the framing line so
- the LLM gets the "you're inside the story" instruction even
- when no other perception is cached.
-
- Trailing newline included so callers can concatenate without
- padding.
- """
- lines: list[str] = []
-
- # Story-mode framing — appears first so the rest of the
- # perception ("you see X") reads as in-story sensation.
- if (self.state or "").lower() == "story_time":
- lines.append(STORY_FRAMING_LINE)
-
- if self.face == "identified" and self.face_id:
- who_line = f"You see {self.face_id} in front of you."
- if self.face_mood:
- who_line += f" They look {self.face_mood}."
- lines.append(who_line)
- elif self.face == "detected":
- who_line = "You see an unrecognised face in front of you."
- if self.face_mood:
- who_line += f" They look {self.face_mood}."
- lines.append(who_line)
-
- # Prefer the synth sentence (composed, sentence-shaped) over raw
- # vision/audio. The synth loop already merges those signals.
- if self.scene_synth:
- lines.append(self.scene_synth.strip())
- else:
- if self.last_vision_desc:
- lines.append(f"You see: {self.last_vision_desc.strip()}")
- if self.last_audio_desc:
- lines.append(f"You hear: {self.last_audio_desc.strip()}")
-
- if not lines:
- return ""
- return "[Current perception] " + " ".join(lines) + "\n"
-
-
-def _age_or_none(wall_ts) -> float | None:
- if not isinstance(wall_ts, (int, float)):
- return None
- return max(0.0, time.time() - float(wall_ts))
-
-
-def snapshot(
- device_id: str | None,
- *,
- perception_state: dict,
- vision_cache: dict,
- audio_cache: dict,
- scene_synthesis_cache: dict,
-) -> PerceptionSnapshot:
- """Compose a frozen snapshot from the four bridge caches.
-
- All four cache dicts are passed in explicitly — this module never
- imports from bridge.py, so tests can construct synthetic caches
- directly without monkey-patching.
- """
- pstate: dict = perception_state.get(device_id, {}) if device_id else {}
-
- # Identification is TTL-bound on `last_face_recognized_t`, not on
- # `face_present`. The HuMan detector flickers (face_lost/face_detected
- # pairs ~1 s apart) — using face_present alone collapsed identity to
- # "detected" within a second of the room-view VLM matching. We keep
- # the chip green for FACE_IDENTITY_AGE_GATE_SEC after the last match.
- face: FaceState = "off"
- face_id: str | None = None
- identity = (pstate.get("last_face_id") or "").strip()
- last_recog_age = _age_or_none(pstate.get("last_face_recognized_t"))
- identified_fresh = (
- identity
- and identity != "unknown"
- and last_recog_age is not None
- and last_recog_age <= FACE_IDENTITY_AGE_GATE_SEC
- )
- if identified_fresh:
- face = "identified"
- face_id = identity
- elif pstate.get("face_present"):
- face = "detected"
-
- # Mood is set by _parse_room_view_response when the VLM returns a
- # value from _ROOM_VIEW_MOODS. Bound to the identification: lives as
- # long as the identified-face TTL, scrubbed once the face is no longer
- # identified or fresh.
- raw_mood = (pstate.get("face_mood") or "").strip().lower()
- mood_age = _age_or_none(pstate.get("face_mood_t"))
- mood_fresh = (
- bool(raw_mood)
- and mood_age is not None
- and mood_age <= FACE_IDENTITY_AGE_GATE_SEC
- )
- face_mood = raw_mood if mood_fresh else None
- if face != "identified":
- face_mood = None
-
- listening = bool(pstate.get("listening"))
- state = pstate.get("current_state") or "idle"
-
- last_vision_desc: str | None = None
- last_vision_age_s: float | None = None
- if device_id:
- v = vision_cache.get(device_id) or {}
- age = _age_or_none(v.get("wall_ts"))
- desc = (v.get("description") or "").strip()
- if desc and age is not None and age <= VISION_AGE_GATE_SEC:
- last_vision_desc = desc
- last_vision_age_s = age
-
- last_audio_desc: str | None = None
- last_audio_age_s: float | None = None
- if device_id:
- a = audio_cache.get(device_id) or {}
- age = _age_or_none(a.get("wall_ts"))
- desc = (a.get("description") or "").strip()
- if desc and age is not None and age <= AUDIO_AGE_GATE_SEC:
- last_audio_desc = desc
- last_audio_age_s = age
-
- # NB: the scene-synthesis cache uses `ts_wall` (not `wall_ts`) —
- # see _scene_synthesis_cache writer in bridge.py. Both spellings
- # exist in the codebase for historical reasons.
- scene_synth: str | None = None
- scene_synth_age_s: float | None = None
- if device_id:
- s = scene_synthesis_cache.get(device_id) or {}
- age = _age_or_none(s.get("ts_wall"))
- text = (s.get("text") or "").strip()
- if text and age is not None and age <= SCENE_SYNTH_AGE_GATE_SEC:
- scene_synth = text
- scene_synth_age_s = age
-
- return PerceptionSnapshot(
- state=state,
- face=face,
- face_id=face_id,
- face_mood=face_mood,
- listening=listening,
- last_vision_desc=last_vision_desc,
- last_vision_age_s=last_vision_age_s,
- last_audio_desc=last_audio_desc,
- last_audio_age_s=last_audio_age_s,
- scene_synth=scene_synth,
- scene_synth_age_s=scene_synth_age_s,
- )
diff --git a/bridge/proactive_greeter.py b/bridge/proactive_greeter.py
deleted file mode 100644
index bae9afc..0000000
--- a/bridge/proactive_greeter.py
+++ /dev/null
@@ -1,574 +0,0 @@
-"""Layer 6 — Proactive Greeter.
-
-Subscribes to perception-bus `face_recognized` events (and optionally
-`face_detected` while Layer 4 face recognition is being wired up) and
-pushes a context-aware greeting to the device when:
-
- - the recognised identity hasn't been greeted recently (cooldown),
- - per-day greeting cap hasn't been hit.
-
-Time-of-day windows (morning / afternoon / evening) used to gate
-whether a greeting fired at all; that gate was removed 2026-04-27 so
-recognition reliably produces a greeting regardless of hour. The
-time-of-day label is still derived from current hour for natural
-phrasing in the prompt and template fallback.
-
-The greeting is generated by the same LLM as voice turns (via an
-injected `llm_client`) using `summarize_for_prompt(events, person=name)`
-to pull only that person's calendar items for today. A short template
-fallback fires whenever the LLM is unreachable, so the greeter never
-silently no-ops because of a transient upstream failure.
-
-All external calls (LLM, TTS push, calendar lookup, state I/O) are
-wrapped — a greeter failure must never break the voice path or the
-perception bus.
-
-State
------
-Per-person greet log is persisted at `GREETER_STATE_PATH`
-(default `~/.zeroclaw/greeter_state.json`) so a bridge restart doesn't
-re-greet everyone in the house. Atomic write (temp + rename); on JSON
-parse failure we start fresh rather than crash.
-"""
-from __future__ import annotations
-
-import asyncio
-import json
-import logging
-import os
-import time
-from datetime import datetime
-from pathlib import Path
-from typing import Any, Awaitable, Callable, Optional
-from zoneinfo import ZoneInfo
-
-log = logging.getLogger("zeroclaw-bridge.proactive_greeter")
-
-
-# ---------------------------------------------------------------------------
-# Configuration helpers
-# ---------------------------------------------------------------------------
-
-
-def _env_bool(key: str, default: bool) -> bool:
- raw = os.environ.get(key)
- if raw is None:
- return default
- return raw.strip().lower() in ("1", "true", "yes", "on")
-
-
-def _env_float(key: str, default: float) -> float:
- try:
- return float(os.environ.get(key, default))
- except (TypeError, ValueError):
- return default
-
-
-def _env_int(key: str, default: int) -> int:
- try:
- return int(os.environ.get(key, default))
- except (TypeError, ValueError):
- return default
-
-
-# ---------------------------------------------------------------------------
-# Greeter
-# ---------------------------------------------------------------------------
-
-
-class ProactiveGreeter:
- """Subscribes to perception events and emits proactive greetings.
-
- Dependencies are injected so the class is unit-testable without
- needing to spin up the FastAPI app. Concretely, ``perception_bus``
- is any object with ``subscribe()`` / ``unsubscribe(q)`` returning
- an ``asyncio.Queue``.
-
- Parameters
- ----------
- perception_bus :
- Object exposing ``subscribe()`` -> ``asyncio.Queue`` and
- ``unsubscribe(q)``. In production this wraps the in-process
- listeners list in ``bridge.py``.
- llm_client :
- Async callable ``(prompt: str) -> str`` that returns a single
- completion. May raise — we always have a template fallback.
- calendar_cache :
- Object with ``get_events()`` and ``summarize_for_prompt(events,
- person, include_household)``. Either may raise; we degrade to
- no calendar context.
- tts_pusher :
- Async callable ``(device_id: str, text: str) -> Awaitable``
- that delivers the greeting to the device. Errors are logged
- but never propagated.
- kid_mode_provider :
- Callable returning the current kid-mode flag (bool). Sampled
- every greeting so a dashboard flip takes effect without a restart.
- """
-
- DEFAULT_STATE_PATH = "~/.zeroclaw/greeter_state.json"
-
- def __init__(
- self,
- perception_bus: Any,
- llm_client: Callable[[str], Awaitable[str]],
- calendar_cache: Any,
- tts_pusher: Callable[[str, str], Awaitable[Any]],
- kid_mode_provider: Callable[[], bool],
- *,
- household_registry: Any = None,
- turn_logger: Optional[Callable[..., None]] = None,
- clock: Callable[[], float] = time.time,
- tz: Optional[ZoneInfo] = None,
- ) -> None:
- self._bus = perception_bus
- self._llm = llm_client
- self._calendar = calendar_cache
- self._tts = tts_pusher
- self._kid_mode = kid_mode_provider
- # Optional. When supplied, every greeting (success or push-error)
- # is mirrored as a synthetic conversation turn with channel="greeter"
- # so the dashboard's unified Activity feed surfaces it alongside
- # voice turns. Inject `_convo_log.log_turn` from bridge.py.
- self._turn_logger = turn_logger
- # Optional. When supplied, we enrich greetings with display name,
- # personality and birthday awareness. None == legacy behaviour
- # (raw identity string). See bridge/household.py.
- self._household = household_registry
- self._clock = clock
- tz_name = os.environ.get("TZ", "Australia/Brisbane")
- self._tz = tz or ZoneInfo(tz_name)
-
- # Config — sampled at construction; restart to change.
- self.enabled = _env_bool("GREETER_ENABLED", True)
- self.use_face_detected = _env_bool(
- "GREETER_USE_FACE_DETECTED", False)
- self.greet_unknown = _env_bool("GREETER_GREET_UNKNOWN", False)
- self.cooldown_seconds = _env_float(
- "GREETER_COOLDOWN_HOURS", 4.0) * 3600.0
- self.per_day_max = _env_int("GREETER_PER_DAY_MAX", 3)
- self.greeting_max_words = _env_int("GREETER_GREETING_MAX_WORDS", 15)
-
- state_path_raw = os.environ.get(
- "GREETER_STATE_PATH", self.DEFAULT_STATE_PATH)
- self._state_path = Path(state_path_raw).expanduser()
-
- # In-memory greet log: {date_str: {identity: {"count": N, "last_ts": float}}}
- self._state: dict[str, dict[str, dict[str, Any]]] = self._load_state()
-
- self._task: Optional[asyncio.Task] = None
- self._stopped = False
-
- # ------------------------------------------------------------------
- # Lifecycle
- # ------------------------------------------------------------------
- def start(self) -> None:
- if not self.enabled:
- log.info("ProactiveGreeter disabled (GREETER_ENABLED=false)")
- return
- if self._task is not None and not self._task.done():
- log.warning("ProactiveGreeter.start() called twice; ignoring")
- return
- self._stopped = False
- self._task = asyncio.create_task(self._run())
- log.info(
- "ProactiveGreeter started "
- "(use_face_detected=%s greet_unknown=%s cooldown_s=%.0f "
- "per_day_max=%d state=%s)",
- self.use_face_detected, self.greet_unknown,
- self.cooldown_seconds, self.per_day_max, self._state_path,
- )
-
- async def stop(self) -> None:
- self._stopped = True
- if self._task is None:
- return
- self._task.cancel()
- try:
- await self._task
- except (asyncio.CancelledError, Exception):
- pass
- self._task = None
-
- # ------------------------------------------------------------------
- # Event loop
- # ------------------------------------------------------------------
- async def _run(self) -> None:
- try:
- q = self._bus.subscribe()
- except Exception:
- log.exception("ProactiveGreeter: bus.subscribe() failed")
- return
- try:
- while not self._stopped:
- event = await q.get()
- try:
- await self._handle(event)
- except Exception:
- # Defensive — never let a bad event kill the loop.
- log.exception(
- "ProactiveGreeter: handler raised (event=%s)",
- event.get("name") if isinstance(event, dict) else "?",
- )
- except asyncio.CancelledError:
- log.info("ProactiveGreeter cancelled")
- raise
- finally:
- try:
- self._bus.unsubscribe(q)
- except Exception:
- log.debug("ProactiveGreeter: unsubscribe raised", exc_info=True)
-
- async def _handle(self, event: dict) -> None:
- """Dispatch loop hook used by `_run`. Public-ish for tests."""
- if not isinstance(event, dict):
- return
- name = event.get("name") or ""
- if name == "face_recognized":
- return await self._on_face_recognized(event)
- if name == "face_detected" and self.use_face_detected:
- # Synthesise a face_recognized-style payload with identity
- # left as "unknown" so the unknown-face branch handles it.
- return await self._on_face_recognized({
- **event,
- "data": {**(event.get("data") or {}), "identity": "unknown"},
- })
-
- async def _on_face_recognized(self, event: dict) -> None:
- """Main handler. Called for both `face_recognized` events and
- (when enabled) `face_detected` events promoted to unknown."""
- device_id = event.get("device_id") or ""
- if not device_id or device_id == "unknown":
- return
- data = event.get("data") or {}
- identity = (data.get("identity") or "").strip() or "unknown"
-
- # Time-of-day label is now informational only (used to phrase the
- # greeting); it never gates whether a greeting fires.
- time_of_day = self._current_window()
-
- t0 = self._clock()
-
- # Unknown branch.
- if identity == "unknown":
- if not self.greet_unknown:
- log.debug(
- "greeter: unknown face skipped (GREETER_GREET_UNKNOWN=false)"
- )
- return
- if not self._take_slot(identity, event_ts=event.get("ts")):
- return
- text = self._sandwich(
- "Hello! I don't think we've met.", window=time_of_day,
- )
- await self._safe_push(
- device_id, text, t0=t0,
- request_text=f"face:{identity}",
- )
- return
-
- # Known identity.
- if not self._take_slot(identity, event_ts=event.get("ts")):
- return
-
- greeting = await self._generate_greeting(
- identity=identity, window=time_of_day,
- )
- text = self._sandwich(greeting, window=time_of_day)
- await self._safe_push(
- device_id, text, t0=t0,
- request_text=f"face:{identity}",
- )
-
- # ------------------------------------------------------------------
- # Time-of-day label
- # ------------------------------------------------------------------
- def _current_window(self) -> str:
- """Return a time-of-day label always — formerly gated greeting
- on membership in configured windows; that gate was removed
- 2026-04-27 so a recognised face produces a greeting at any
- hour, subject to cooldown and per-day cap. The label is still
- passed through to the LLM prompt and template fallback for
- natural phrasing ("Good morning, Brett!" vs "Good evening, …")."""
- now = datetime.fromtimestamp(self._clock(), tz=self._tz)
- hour = now.hour
- if 5 <= hour < 12:
- return "morning"
- if 12 <= hour < 17:
- return "afternoon"
- if 17 <= hour < 21:
- return "evening"
- return "night"
-
- # ------------------------------------------------------------------
- # Cooldown / per-day cap
- # ------------------------------------------------------------------
- def _today_key(self) -> str:
- return datetime.fromtimestamp(self._clock(), tz=self._tz).strftime("%Y-%m-%d")
-
- def _take_slot(self, identity: str, *, event_ts: Optional[float]) -> bool:
- """Return True iff this identity is allowed to be greeted now;
- records the slot if so. Combines cooldown + per-day cap."""
- now = float(event_ts) if event_ts else self._clock()
- today = self._today_key()
- # Garbage-collect old days so the file doesn't grow unbounded.
- if today not in self._state:
- self._state = {today: self._state.get(today, {})}
- bucket = self._state.setdefault(today, {})
- slot = bucket.get(identity, {"count": 0, "last_ts": 0.0})
- if slot["count"] >= self.per_day_max:
- log.info(
- "greeter: per-day cap reached for identity=%s (count=%d)",
- identity, slot["count"],
- )
- return False
- last_ts = float(slot.get("last_ts") or 0.0)
- # last_ts == 0 is the sentinel meaning "never greeted" — don't
- # apply the cooldown to a first greeting, only to repeats.
- if last_ts > 0.0 and now - last_ts < self.cooldown_seconds:
- log.info(
- "greeter: cooldown active for identity=%s "
- "(%.0fs since last)",
- identity, now - last_ts,
- )
- return False
- slot["count"] = int(slot["count"]) + 1
- slot["last_ts"] = now
- bucket[identity] = slot
- self._save_state()
- return True
-
- # ------------------------------------------------------------------
- # Greeting generation
- # ------------------------------------------------------------------
- async def _generate_greeting(
- self, *, identity: str, window: str,
- ) -> str:
- """Build the greeting text. LLM call with template fallback."""
- events_summary: list[str] = []
- try:
- events = self._calendar.get_events()
- events_summary = self._calendar.summarize_for_prompt(
- events, person=identity, include_household=True,
- ) or []
- except Exception:
- log.warning(
- "greeter: calendar lookup failed for %s; continuing without",
- identity, exc_info=True,
- )
-
- prompt = self._build_prompt(
- identity=identity, window=window, events=events_summary,
- )
- try:
- text = await self._llm(prompt)
- cleaned = self._post_process(text or "")
- if cleaned:
- return cleaned
- log.info("greeter: LLM returned empty; using template fallback")
- except Exception:
- log.warning(
- "greeter: LLM call failed; using template fallback",
- exc_info=True,
- )
- return self._template_fallback(identity=identity, window=window)
-
- def _build_prompt(
- self, *, identity: str, window: str, events: list[str],
- ) -> str:
- bullet_block = (
- "\n".join(f"- {e}" for e in events) if events else "(none)"
- )
- max_words = self.greeting_max_words
-
- # Registry enrichment — display name, compact persona, and
- # birthday awareness. Fall through to raw identity when absent.
- person = self._lookup_person(identity)
- addressee = person.display_name if person else identity
- persona_line = ""
- birthday_line = ""
- if person is not None:
- descr = person.compact_description(max_chars=180)
- if descr:
- persona_line = f"About {addressee}: {descr}\n"
- days = person.days_until_birthday()
- if days is not None:
- if days == 0:
- birthday_line = (
- f"It is {addressee}'s birthday today — "
- f"a warm acknowledgement is welcome.\n"
- )
- elif 1 <= days <= 7:
- birthday_line = (
- f"{addressee}'s birthday is in {days} day"
- f"{'s' if days != 1 else ''} — you may mention "
- f"it lightly if it fits.\n"
- )
-
- return (
- f"You are Dotty, a friendly home robot. {addressee} just "
- f"walked into the room. The time of day is {window}.\n"
- f"{persona_line}"
- f"{birthday_line}"
- f"Today's calendar items relevant to {addressee}:\n"
- f"{bullet_block}\n\n"
- f"Write a single short, warm spoken greeting addressed to "
- f"{addressee}. If a calendar item is highly relevant to the "
- f"current time-of-day window, you may mention it naturally — "
- f"otherwise just greet them. "
- f"Hard rules: ENGLISH ONLY. {max_words} words or fewer. "
- f"One sentence. No emoji, no Markdown, no lists."
- )
-
- def _lookup_person(self, identity: str) -> Any:
- """Look up a person in the registry. Returns None when no
- registry is wired or identity is unknown to it. Defensive — a
- registry hiccup must never break the greeter."""
- if not self._household or not identity or identity == "unknown":
- return None
- try:
- return self._household.get(identity)
- except Exception:
- log.debug(
- "greeter: household registry lookup failed for %s",
- identity, exc_info=True,
- )
- return None
-
- @staticmethod
- def _post_process(text: str) -> str:
- cleaned = " ".join(text.strip().split())
- # Strip wrapping quotes the LLM likes to add.
- if len(cleaned) >= 2 and cleaned[0] in "\"'" and cleaned[-1] in "\"'":
- cleaned = cleaned[1:-1].strip()
- return cleaned
-
- def _template_fallback(self, *, identity: str, window: str) -> str:
- person = self._lookup_person(identity)
- addressee = person.display_name if person else identity
- return f"Good {window}, {addressee}!"
-
- # ------------------------------------------------------------------
- # Kid-safe sandwich
- # ------------------------------------------------------------------
- def _sandwich(self, text: str, *, window: str) -> str:
- """Wrap the greeting with the same safety contract voice turns
- get. We deliberately keep this lightweight here because the
- greeting is not user-driven — it's server-generated and already
- constrained by the prompt above. The wrapper exists primarily
- to (a) keep the surface uniform and (b) ensure kid-mode rules
- propagate if the greeting later flows through any general-purpose
- path that re-interprets it."""
- # Local import to avoid a circular dependency on bridge.py — the
- # actual VOICE_TURN_PREFIX/SUFFIX constants live in bridge.py and
- # are not needed for the *output* text we send to TTS, which must
- # be plain spoken English. The sandwich is applied conceptually
- # by the prompt, and the output here is what the device will
- # actually speak.
- try:
- kid = bool(self._kid_mode())
- except Exception:
- kid = True # Default to safer when in doubt.
- if kid:
- # Defensive scrub — strip stray emoji prefix patterns the LLM
- # sometimes adds despite the prompt telling it not to.
- for ch in ("😊", "😆", "😢", "😮", "🤔", "😠", "😐", "😍", "😴"):
- if text.startswith(ch):
- text = text[len(ch):].lstrip()
- break
- return text
-
- # ------------------------------------------------------------------
- # TTS push
- # ------------------------------------------------------------------
- async def _safe_push(
- self,
- device_id: str,
- text: str,
- *,
- t0: Optional[float] = None,
- request_text: str = "",
- ) -> None:
- if not text:
- return
- err: Optional[str] = None
- try:
- await self._tts(device_id, text)
- log.info(
- "greeter: pushed greeting device=%s text=%r", device_id, text,
- )
- except Exception as exc:
- log.exception(
- "greeter: tts_pusher raised (device=%s)", device_id,
- )
- err = repr(exc)[:200]
- if self._turn_logger is not None:
- latency_ms = (
- (self._clock() - t0) * 1000.0 if t0 is not None else 0.0
- )
- try:
- self._turn_logger(
- channel="greeter",
- session_id="",
- request_text=request_text,
- response_text=text,
- latency_ms=latency_ms,
- error=err,
- )
- except Exception:
- log.debug(
- "greeter: turn_logger raised", exc_info=True,
- )
-
- # ------------------------------------------------------------------
- # State persistence
- # ------------------------------------------------------------------
- def _load_state(self) -> dict[str, dict[str, dict[str, Any]]]:
- try:
- if not self._state_path.exists():
- return {}
- raw = self._state_path.read_text(encoding="utf-8")
- data = json.loads(raw)
- if not isinstance(data, dict):
- log.warning("greeter: state file not a dict; starting fresh")
- return {}
- # Coerce shape — accept anything saved by an older version.
- out: dict[str, dict[str, dict[str, Any]]] = {}
- for day, bucket in data.items():
- if not isinstance(bucket, dict):
- continue
- inner: dict[str, dict[str, Any]] = {}
- for identity, slot in bucket.items():
- if isinstance(slot, dict):
- inner[identity] = {
- "count": int(slot.get("count") or 0),
- "last_ts": float(slot.get("last_ts") or 0.0),
- }
- out[str(day)] = inner
- return out
- except (OSError, json.JSONDecodeError, ValueError):
- log.warning(
- "greeter: state file unreadable; starting fresh",
- exc_info=True,
- )
- return {}
-
- def _save_state(self) -> None:
- try:
- self._state_path.parent.mkdir(parents=True, exist_ok=True)
- tmp = self._state_path.with_suffix(
- self._state_path.suffix + ".tmp"
- )
- tmp.write_text(
- json.dumps(self._state, separators=(",", ":")),
- encoding="utf-8",
- )
- os.replace(tmp, self._state_path)
- except OSError:
- log.warning(
- "greeter: failed to persist state to %s",
- self._state_path, exc_info=True,
- )
-
-
-__all__ = ["ProactiveGreeter"]
diff --git a/bridge/purr_player.py b/bridge/purr_player.py
deleted file mode 100644
index 2626d5b..0000000
--- a/bridge/purr_player.py
+++ /dev/null
@@ -1,164 +0,0 @@
-"""Perception consumer: cat-purr audio on head_pet_started events.
-
-Extracted from bridge.py for testability. The parent bridge.py imports
-``run_purr_consumer`` and schedules it alongside the perception event bus.
-
-Environment variables (read at import time so test fixtures can patch
-``os.environ`` before importing this module):
-
- PURR_AUDIO_PATH — path to the pre-rendered purr clip
- (default: bridge/assets/purr.opus)
- PURR_COOLDOWN_SEC — per-device repeat-suppression window in seconds
- (default: 5)
- PURR_DURATION_SEC — approximate playback length; used to extend
- ``last_chat_t`` so the sound-localiser stays quiet
- during playback (default: 2.0)
- XIAOZHI_HOST — hostname/IP of the xiaozhi-server HTTP admin API
- (no default; empty string disables dispatch)
- XIAOZHI_OTA_PORT — xiaozhi-server HTTP port (default: 8003)
-"""
-from __future__ import annotations
-
-import asyncio
-import logging
-import os
-import time
-from pathlib import Path
-from typing import Awaitable, Callable
-
-log = logging.getLogger("zeroclaw-bridge.purr_player")
-
-# Pin fire-and-forget tasks so asyncio's weakref doesn't GC them
-# mid-flight. See bridge.py for the same pattern in production hot path.
-_BACKGROUND_TASKS: set[asyncio.Task] = set()
-
-
-def _spawn(coro, *, name: str | None = None) -> asyncio.Task:
- t = asyncio.create_task(coro, name=name)
- _BACKGROUND_TASKS.add(t)
- t.add_done_callback(_BACKGROUND_TASKS.discard)
- return t
-
-PURR_AUDIO_PATH: Path = Path(
- os.environ.get("PURR_AUDIO_PATH", "bridge/assets/purr.opus")
-)
-PURR_COOLDOWN_SEC: float = float(os.environ.get("PURR_COOLDOWN_SEC", "5"))
-PURR_DURATION_SEC: float = float(os.environ.get("PURR_DURATION_SEC", "2.0"))
-
-_XIAOZHI_HOST: str = os.environ.get("XIAOZHI_HOST", "")
-_XIAOZHI_HTTP_PORT: int = int(os.environ.get("XIAOZHI_OTA_PORT", "8003"))
-
-
-async def dispatch_purr_audio(
- device_id: str,
- *,
- purr_path: Path | None = None,
- xiaozhi_host: str | None = None,
- xiaozhi_port: int | None = None,
-) -> bool:
- """POST the purr asset path to xiaozhi-server's /play-asset admin route.
-
- Returns True on 2xx, False on any failure. Never raises.
-
- Keyword overrides (purr_path, xiaozhi_host, xiaozhi_port) allow tests to
- supply controlled values without patching environment variables.
- """
- path = purr_path if purr_path is not None else PURR_AUDIO_PATH
- host = xiaozhi_host if xiaozhi_host is not None else _XIAOZHI_HOST
- port = xiaozhi_port if xiaozhi_port is not None else _XIAOZHI_HTTP_PORT
-
- if not host:
- log.warning("purr: XIAOZHI_HOST not set; cannot reach xiaozhi-server")
- return False
-
- import requests as _req
-
- url = f"http://{host}:{port}/xiaozhi/admin/play-asset"
- payload = {"device_id": device_id, "asset": str(path)}
-
- def _post() -> bool:
- try:
- r = _req.post(url, json=payload, timeout=3)
- if r.status_code >= 400:
- log.warning(
- "purr play-asset %s: %s", r.status_code, r.text[:200]
- )
- return False
- return True
- except Exception as exc:
- log.warning("purr play-asset failed: %s", exc)
- return False
-
- try:
- return await asyncio.to_thread(_post)
- except Exception:
- log.exception("purr dispatch raised")
- return False
-
-
-SubscribeFn = Callable[[], "asyncio.Queue[dict]"] # type: ignore[type-arg]
-DispatchFn = Callable[[str], Awaitable[bool]]
-
-
-async def run_purr_consumer(
- subscribe_fn: SubscribeFn,
- perception_state: dict,
- *,
- cooldown_sec: float | None = None,
- duration_sec: float | None = None,
- dispatch_fn: DispatchFn | None = None,
-) -> None:
- """Subscribe to the perception event bus and purr on head_pet_started.
-
- Each ``head_pet_started`` event dispatches purr audio for the
- originating device, subject to a per-device cooldown. After dispatch,
- ``last_chat_t`` in ``perception_state`` is extended by ``duration_sec``
- so the sound-localiser (``_perception_sound_turner``) skips head-turn
- commands while the purr plays.
-
- Parameters
- ----------
- subscribe_fn:
- Zero-argument callable that returns an ``asyncio.Queue`` delivering
- perception event dicts (keys: ``name``, ``device_id``, ``ts``).
- perception_state:
- Shared per-device state dict; mutated in place.
- cooldown_sec:
- Per-device cooldown between purrs. Defaults to PURR_COOLDOWN_SEC.
- duration_sec:
- Purr playback duration for ``last_chat_t`` suppression.
- Defaults to PURR_DURATION_SEC.
- dispatch_fn:
- Async callable ``(device_id) -> bool`` that sends the audio.
- Defaults to ``dispatch_purr_audio``. Inject a mock in tests.
- """
- cooldown = cooldown_sec if cooldown_sec is not None else PURR_COOLDOWN_SEC
- duration = duration_sec if duration_sec is not None else PURR_DURATION_SEC
- dispatch = dispatch_fn if dispatch_fn is not None else dispatch_purr_audio
-
- log.info(
- "purr consumer started (cooldown=%.0fs asset=%s)", cooldown, PURR_AUDIO_PATH
- )
- q = subscribe_fn()
- try:
- while True:
- event = await q.get()
- if event.get("name") != "head_pet_started":
- continue
- device_id = event.get("device_id", "")
- if not device_id or device_id == "unknown":
- continue
- now = float(event.get("ts") or time.time())
- state = perception_state.setdefault(device_id, {})
- last_purr = state.get("last_purr_t", 0.0)
- if now - last_purr < cooldown:
- continue
- state["last_purr_t"] = now
- state["last_chat_t"] = now + duration
- log.info("head_pet_started → purr: device=%s", device_id)
- _spawn(dispatch(device_id), name="purr_dispatch")
- except asyncio.CancelledError:
- log.info("purr consumer cancelled")
- raise
- except Exception:
- log.exception("purr consumer crashed")
diff --git a/bridge/sensor_feed.html b/bridge/sensor_feed.html
deleted file mode 100644
index c2e4c57..0000000
--- a/bridge/sensor_feed.html
+++ /dev/null
@@ -1,150 +0,0 @@
-
-
-
-
-
-Dotty — Live Sensor Feed
-
-
-
-Dotty · Live Sensor Feed
-
- Disconnected
-
-
- 0 events
-
-
-
-
-
-
diff --git a/bridge/server_push.py b/bridge/server_push.py
deleted file mode 100644
index 6f09cd9..0000000
--- a/bridge/server_push.py
+++ /dev/null
@@ -1,125 +0,0 @@
-"""Server-push helper for proactive utterances.
-
-Layer 6 (proactive greetings) and any future server-initiated TTS path
-go through this module so the server-push mechanism is documented
-in one place.
-
-NOTES on server-push mechanics
-------------------------------
-
-a) **xiaozhi WS protocol DOES support server-pushed TTS framing.**
- The existing dance audio path proves it: the server can push
- `tts/sentence_start`-style frames followed by Opus audio chunks
- directly down the device websocket without the device having
- first opened the mic. The same channel is used by the
- `/xiaozhi/admin/inject-text` and `/xiaozhi/admin/inject-tts`
- admin routes today.
-
-b) **Current implementation reuses inject-text.** The simplest
- server-push surface (and the one Layer 1.5's face-greeter
- already uses) is HTTP POST to the xiaozhi-server admin endpoint.
- xiaozhi-server then runs the text through its configured TTS
- provider and pushes the resulting audio frames to the device.
- That goes through the normal TTS pipeline (cost, latency,
- selected voice) but requires zero firmware changes — which is
- why we start there for the proactive greeter.
-
-c) **Future option: bypass-xiaozhi direct-Opus push** for lower
- latency and to bypass the TTS provider entirely (e.g., to play
- a pre-rendered Opus blob cached for "Good morning, Hudson!").
- That requires the bridge to either (a) hold its own websocket
- to the device, or (b) ask xiaozhi-server to forward an
- already-encoded Opus payload via a new admin route. Tracked as
- future work — not blocking Layer 6 scaffolding.
-
-The function `push_greeting_audio(...)` exposed here is the
-single chokepoint Layer 6 calls; flipping its implementation to a
-direct-Opus path later does not require touching the greeter.
-"""
-from __future__ import annotations
-
-import asyncio
-import logging
-import os
-from typing import Awaitable, Callable, Optional
-
-log = logging.getLogger("zeroclaw-bridge.server_push")
-
-
-# Resolved lazily so tests can patch env without importing bridge.py.
-def _xiaozhi_admin_url() -> Optional[str]:
- host = os.environ.get("XIAOZHI_HOST", "")
- if not host:
- return None
- port = int(os.environ.get("XIAOZHI_OTA_PORT", "8003"))
- return f"http://{host}:{port}/xiaozhi/admin/inject-text"
-
-
-async def push_greeting_audio(
- device_id: str,
- text: str,
- *,
- timeout: float = 3.0,
- inject_text_fn: Optional[Callable[[str, str], Awaitable[None]]] = None,
-) -> bool:
- """Push a proactive utterance to a device.
-
- Routes through the existing xiaozhi-server inject-text admin route,
- same path the Layer 1.5 face-greeter uses. Returns True on
- successful POST, False on any error. NEVER raises.
-
- `inject_text_fn` (optional): an alternative async callable that
- accepts (device_id, text). Used for tests and to allow bridge.py
- to inject its own helper without us re-importing it (avoids a
- circular import).
- """
- if not text:
- log.warning("push_greeting_audio: empty text, skipping")
- return False
- if not device_id or device_id == "unknown":
- log.warning("push_greeting_audio: missing device_id, skipping")
- return False
-
- if inject_text_fn is not None:
- try:
- await inject_text_fn(device_id, text)
- return True
- except Exception:
- log.exception("push_greeting_audio: inject_text_fn raised")
- return False
-
- url = _xiaozhi_admin_url()
- if not url:
- log.warning(
- "push_greeting_audio: XIAOZHI_HOST not set; cannot reach "
- "xiaozhi-server (device=%s)",
- device_id,
- )
- return False
-
- payload = {"text": text, "device_id": device_id}
-
- def _post() -> bool:
- try:
- import requests as _req
-
- r = _req.post(url, json=payload, timeout=timeout)
- if r.status_code >= 400:
- log.warning(
- "push_greeting_audio inject-text %s: %s",
- r.status_code, r.text[:200],
- )
- return False
- return True
- except Exception as exc:
- log.warning("push_greeting_audio inject-text failed: %s", exc)
- return False
-
- try:
- return await asyncio.to_thread(_post)
- except Exception:
- log.exception("push_greeting_audio: to_thread raised")
- return False
-
-
-__all__ = ["push_greeting_audio"]
diff --git a/bridge/speaker.py b/bridge/speaker.py
deleted file mode 100644
index 3a8022c..0000000
--- a/bridge/speaker.py
+++ /dev/null
@@ -1,642 +0,0 @@
-"""SpeakerResolver — "who is talking to Dotty?".
-
-The resolver is a small, deterministic, dependency-light combiner that
-turns four weak signals into a single best-guess `Person` plus a
-confidence score. Phase 1 of the family-companion identity work; sits
-in front of the LLM call so every voice turn can be grounded in "we're
-talking to Hudson, not just _household."
-
-Why combine signals
--------------------
-Without on-device face recognition (Layer 4 — pending firmware), no
-single signal is reliable:
-
- - **Self-ID** (utterances like "It's Brett") is unambiguous when
- present, but absent on most turns.
- - **Calendar** (today's `[Person]` events near now) is great in a
- routine household but silent on weekends / unscheduled time.
- - **Time-of-day** prior is cheap and surprisingly accurate for kids
- after school / parents in the evening, but degenerates for any
- non-routine moment.
- - **Perception** (recent `face_detected`) tells us *someone is here*
- even before face_recognized lands; it gates the resolver against
- inventing a speaker when the room is empty.
-
-Each signal contributes a weighted vote; the combiner returns the
-top-1 person plus the per-signal evidence that picked them, so we can
-audit every decision.
-
-Sticky behaviour
-----------------
-A self-ID match latches the channel onto that person for
-`SPEAKER_STICKY_SEC` (default 600 s). The user's natural behaviour is
-to talk to Dotty for several turns in one sitting — re-resolving from
-scratch every turn would (a) wobble, (b) drop the user mid-thought if
-calendar/time priors disagree. The latch survives ACP session
-rotation by living bridge-side. A new self-ID phrase always wins ("no
-wait, it's Brett") — so anyone can correct an identification
-explicitly.
-
-Privacy
--------
-Resolver output is *only* a registry id + the data the registry
-already exposes. No raw voice fingerprints, no photos, no biometric-
-adjacent material. All decisions persist to a small audit table for
-tuning; that table never leaves the ZeroClaw host.
-"""
-from __future__ import annotations
-
-import logging
-import os
-import threading
-import time
-from dataclasses import dataclass
-from datetime import datetime
-from typing import Any, Iterable, Optional
-from zoneinfo import ZoneInfo
-
-log = logging.getLogger("zeroclaw-bridge.speaker")
-
-
-# ---------------------------------------------------------------------------
-# Signal labels — string constants are easier to grep across logs than enums.
-# ---------------------------------------------------------------------------
-SIG_SELF_ID = "self_id"
-SIG_STICKY = "sticky"
-SIG_CALENDAR = "calendar"
-SIG_TIME_OF_DAY = "time_of_day"
-SIG_PERCEPTION = "perception"
-SIG_VLM_MATCH = "vlm_match"
-SIG_FALLBACK = "fallback"
-
-# Default weights — small integers chosen so a single self-ID dominates,
-# stickiness carries inertia, and prior-only resolutions stay below the
-# clarification threshold so we ASK rather than guess.
-#
-# SIG_VLM_MATCH is the room_view roster identification (description-based,
-# no biometrics). Weight 0.6 sits between sticky (0.70) and perception
-# (0.40): a fresh visual match should beat the calendar/time priors
-# decisively but should NOT override an explicit self-ID ("no it's
-# Brett") — and should also not flap against a still-warm sticky latch
-# from a previous self-ID, since the latch is the user's expressed
-# intent ("this is who I am") whereas the VLM match is a guess.
-DEFAULT_WEIGHTS: dict[str, float] = {
- SIG_SELF_ID: 0.95,
- SIG_STICKY: 0.70,
- SIG_VLM_MATCH: 0.60,
- SIG_PERCEPTION: 0.40, # face_recognized when Layer 4 ships; lower until then
- SIG_CALENDAR: 0.25,
- SIG_TIME_OF_DAY: 0.15,
-}
-
-# Time-of-day bucket boundaries (24h, local TZ). Buckets overlap on
-# purpose at the seams so the prior is non-zero across transitions.
-_TIME_BUCKETS: tuple[tuple[str, int, int], ...] = (
- ("morning", 6 * 60, 11 * 60),
- ("afternoon", 11 * 60, 15 * 60),
- ("after-school", 15 * 60, 18 * 60),
- ("early-evening", 18 * 60, 20 * 60),
- ("evening", 20 * 60, 22 * 60),
- ("night", 22 * 60, 24 * 60 + 6 * 60), # 22:00 → 06:00 next day
-)
-
-
-# ---------------------------------------------------------------------------
-# Public dataclasses
-# ---------------------------------------------------------------------------
-@dataclass(frozen=True)
-class SignalVote:
- """A single piece of evidence pointing at one person."""
-
- signal: str
- person_id: str
- weight: float
- evidence: str = ""
-
-
-@dataclass(frozen=True)
-class SpeakerResolution:
- """Resolver output. `person_id` is None when nothing matched (the
- bridge falls back to the registry's default_person, typically
- `_household`). `addressee` is the registry display name for prompt
- insertion. `ask_clarification` is True when confidence is below the
- threshold AND we have at least one candidate — the bridge can choose
- to surface a "is that you, X?" question."""
-
- person_id: Optional[str]
- addressee: Optional[str]
- confidence: float
- votes: tuple[SignalVote, ...] = ()
- ask_clarification: bool = False
- runner_up_id: Optional[str] = None
- runner_up_confidence: float = 0.0
-
-
-@dataclass
-class _StickyState:
- """Per-channel latch state."""
-
- person_id: str
- set_ts: float
- source: str = SIG_SELF_ID # what made us latch (today: only self_id)
-
-
-# ---------------------------------------------------------------------------
-# Resolver
-# ---------------------------------------------------------------------------
-class SpeakerResolver:
- """Resolves "who is talking" per voice turn.
-
- The resolver is constructed once per bridge process and called
- synchronously on every inbound message. It is thread-safe in the
- asyncio sense: shared mutable state (sticky latches, recent
- perception cache) is guarded by a `threading.Lock` so the rare
- cross-thread call (e.g. a dashboard HTTP handler from FastAPI's thread
- pool) doesn't race with voice traffic.
-
- Parameters
- ----------
- registry :
- `bridge.household.HouseholdRegistry`. Optional — if None, the
- resolver always returns the fallback identity. Wiring stays
- simple and a missing/empty registry never breaks the bridge.
- calendar_provider :
- Callable returning the current cached calendar events
- (list of dicts as produced by `_calendar_cache["events"]`).
- Optional. Errors are swallowed.
- perception_provider :
- Callable returning a list of perception events seen recently
- (each a dict with `name`, `ts`, optional `data.identity`).
- Optional.
- clock :
- `() -> float` for unix-time. Test seam.
- tz :
- zoneinfo.ZoneInfo for time-of-day resolution. Defaults to the
- TZ env var, then Australia/Brisbane.
- """
-
- def __init__(
- self,
- registry: Any = None,
- *,
- calendar_provider: Optional[Any] = None,
- perception_provider: Optional[Any] = None,
- clock: Any = None,
- tz: Optional[ZoneInfo] = None,
- weights: Optional[dict[str, float]] = None,
- ) -> None:
- self._registry = registry
- self._calendar = calendar_provider
- self._perception = perception_provider
- self._clock = clock or time.time
- tz_name = os.environ.get("TZ", "Australia/Brisbane")
- self._tz = tz or ZoneInfo(tz_name)
- self._weights: dict[str, float] = dict(DEFAULT_WEIGHTS)
- if weights:
- self._weights.update(weights)
-
- self.sticky_seconds = _env_float("SPEAKER_STICKY_SEC", 600.0)
- self.calendar_window_min = _env_float(
- "SPEAKER_CALENDAR_WINDOW_MIN", 30.0,
- )
- self.perception_window_sec = _env_float(
- "SPEAKER_PERCEPTION_WINDOW_SEC", 30.0,
- )
- # Below this confidence, surface a "Is that you, X?" hint to the
- # caller. Tunable: real-world calibration probably wants 0.4–0.6.
- self.ask_threshold = _env_float("SPEAKER_ASK_THRESHOLD", 0.5)
-
- self._sticky: dict[str, _StickyState] = {}
- self._lock = threading.Lock()
- # Optional audit hook. The bridge wires this to a SQLite
- # `speaker_decisions` table; keeping it as a callable means the
- # resolver doesn't depend on the storage layer.
- self._audit_hook: Optional[Any] = None
-
- # ------------------------------------------------------------------
- # Configuration
- # ------------------------------------------------------------------
- def set_audit_hook(self, hook: Any) -> None:
- """Register a function called as `hook(resolution, channel,
- request_text)` after every resolve. Errors raised by the hook
- are swallowed."""
- self._audit_hook = hook
-
- # ------------------------------------------------------------------
- # Public API — resolve()
- # ------------------------------------------------------------------
- def resolve(
- self,
- text: str,
- *,
- channel: Optional[str] = None,
- device_id: Optional[str] = None,
- vlm_match_person_id: Optional[str] = None,
- ) -> SpeakerResolution:
- """Run all signals against the inbound utterance and produce a
- single best-guess person.
-
- `channel` and `device_id` together key the sticky latch — most
- deployments use channel alone (one device per channel) but we
- accept both for future multi-device setups.
-
- `vlm_match_person_id` carries the description-based room_view
- identification result (see `bridge.py:_call_vision_api` +
- `_parse_room_view_response`). When the bridge has a fresh VLM
- match for one of the household roster members, pass its
- canonical id here; a SIG_VLM_MATCH vote is appended. Unknown
- ids are silently ignored.
- """
- sticky_key = self._sticky_key(channel, device_id)
- now = float(self._clock())
-
- votes: list[SignalVote] = []
-
- # Signal A — self-ID (highest precedence, also resets sticky).
- self_id_person = self._signal_self_id(text)
- if self_id_person is not None:
- votes.append(SignalVote(
- signal=SIG_SELF_ID,
- person_id=self_id_person,
- weight=self._weights[SIG_SELF_ID],
- evidence=f"matched self-id phrase in {text[:40]!r}",
- ))
- self._set_sticky(sticky_key, self_id_person, source=SIG_SELF_ID, ts=now)
-
- # Signal B — sticky latch (only if not already overridden by self-ID).
- else:
- sticky_id = self._signal_sticky(sticky_key, now)
- if sticky_id is not None:
- votes.append(SignalVote(
- signal=SIG_STICKY,
- person_id=sticky_id,
- weight=self._weights[SIG_STICKY],
- evidence=f"latched within {self.sticky_seconds:.0f}s window",
- ))
-
- # Signal C — calendar prior.
- for vote in self._signal_calendar(now):
- votes.append(vote)
-
- # Signal D — time-of-day prior.
- for vote in self._signal_time_of_day(now):
- votes.append(vote)
-
- # Signal E — recent perception event.
- for vote in self._signal_perception(now):
- votes.append(vote)
-
- # Signal F — VLM room_view roster match (description-based,
- # no biometrics). Validated against the registry so a stale or
- # mistyped person_id from upstream can't pin the resolver to a
- # phantom identity.
- if vlm_match_person_id:
- normalised = vlm_match_person_id.strip().lower()
- if normalised and self._registry is not None:
- try:
- person = self._registry.get(normalised)
- except Exception:
- log.debug("speaker: registry.get raised in vlm_match", exc_info=True)
- person = None
- if person is not None:
- votes.append(SignalVote(
- signal=SIG_VLM_MATCH,
- person_id=person.id,
- weight=self._weights[SIG_VLM_MATCH],
- evidence="room_view VLM matched roster",
- ))
-
- resolution = self._combine(votes, now=now)
- try:
- if self._audit_hook is not None:
- self._audit_hook(resolution, channel or "", text)
- except Exception:
- log.debug("speaker: audit hook raised", exc_info=True)
- return resolution
-
- # ------------------------------------------------------------------
- # Sticky management (also exposed for tests / explicit corrections)
- # ------------------------------------------------------------------
- def force_set_sticky(
- self, channel: Optional[str], device_id: Optional[str], person_id: str,
- ) -> None:
- """Override the sticky latch programmatically — used by the
- dashboard "I am ..." control and by the optional clarification
- flow ("Is that you, Hudson?" → "yes")."""
- self._set_sticky(
- self._sticky_key(channel, device_id),
- person_id,
- source="manual",
- ts=float(self._clock()),
- )
-
- def clear_sticky(
- self, channel: Optional[str] = None, device_id: Optional[str] = None,
- ) -> None:
- with self._lock:
- self._sticky.pop(self._sticky_key(channel, device_id), None)
-
- def peek_sticky(
- self, channel: Optional[str] = None, device_id: Optional[str] = None,
- ) -> Optional[str]:
- """Read-only view used by tests + the /api/speaker/state dashboard
- endpoint. Returns the person_id if the latch is live, else None."""
- key = self._sticky_key(channel, device_id)
- now = float(self._clock())
- with self._lock:
- state = self._sticky.get(key)
- if state is None:
- return None
- if now - state.set_ts > self.sticky_seconds:
- self._sticky.pop(key, None)
- return None
- return state.person_id
-
- # ------------------------------------------------------------------
- # Signal implementations
- # ------------------------------------------------------------------
- def _signal_self_id(self, text: str) -> Optional[str]:
- if self._registry is None:
- return None
- try:
- person = self._registry.match_self_id(text)
- except Exception:
- log.debug("speaker: registry.match_self_id raised", exc_info=True)
- return None
- return person.id if person is not None else None
-
- def _signal_sticky(self, key: str, now: float) -> Optional[str]:
- with self._lock:
- state = self._sticky.get(key)
- if state is None:
- return None
- if now - state.set_ts > self.sticky_seconds:
- self._sticky.pop(key, None)
- return None
- return state.person_id
-
- def _signal_calendar(self, now: float) -> Iterable[SignalVote]:
- if self._calendar is None or self._registry is None:
- return ()
- try:
- events = self._calendar() or []
- except Exception:
- log.debug("speaker: calendar provider raised", exc_info=True)
- return ()
- if not events:
- return ()
-
- window_minutes = self.calendar_window_min
- votes: list[SignalVote] = []
- seen_persons: set[str] = set()
- for ev in events:
- if not isinstance(ev, dict):
- continue
- person_tag = ev.get("person")
- if not person_tag or person_tag.startswith("_"):
- continue
- try:
- person = self._registry.get_by_calendar_prefix(person_tag)
- except Exception:
- continue
- if person is None or person.id in seen_persons:
- continue
- # If the event has a parseable start, weight closer events
- # higher. We scale weight by distance-to-start, capped to the
- # configured window.
- distance_min = self._event_distance_minutes(ev, now=now)
- if distance_min is None:
- # Event of unknown time — give a small flat weight so the
- # signal isn't lost, but well below an in-window event.
- weight = self._weights[SIG_CALENDAR] * 0.4
- elif distance_min > window_minutes:
- continue
- else:
- proximity = max(0.0, 1.0 - (distance_min / window_minutes))
- weight = self._weights[SIG_CALENDAR] * (0.5 + 0.5 * proximity)
- seen_persons.add(person.id)
- votes.append(SignalVote(
- signal=SIG_CALENDAR,
- person_id=person.id,
- weight=weight,
- evidence=(
- f"calendar tag {person_tag!r} within "
- f"{window_minutes:.0f} min window"
- ),
- ))
- return votes
-
- def _signal_time_of_day(self, now: float) -> Iterable[SignalVote]:
- if self._registry is None:
- return ()
- bucket = self._current_time_bucket(now)
- if bucket is None:
- return ()
- day_kind = self._current_day_kind(now)
-
- votes: list[SignalVote] = []
- try:
- people = list(self._registry.iter())
- except Exception:
- log.debug("speaker: registry.iter raised", exc_info=True)
- return ()
- for person in people:
- buckets = person.usual_times.get(day_kind, ()) if person.usual_times else ()
- if not buckets:
- continue
- if "any" in buckets or bucket in buckets:
- votes.append(SignalVote(
- signal=SIG_TIME_OF_DAY,
- person_id=person.id,
- weight=self._weights[SIG_TIME_OF_DAY],
- evidence=f"usual_times[{day_kind}] includes {bucket}",
- ))
- return votes
-
- def _signal_perception(self, now: float) -> Iterable[SignalVote]:
- if self._perception is None:
- return ()
- try:
- events = self._perception() or []
- except Exception:
- log.debug("speaker: perception provider raised", exc_info=True)
- return ()
- votes: list[SignalVote] = []
- seen: set[str] = set()
- cutoff = now - self.perception_window_sec
- for ev in events:
- if not isinstance(ev, dict):
- continue
- ts = float(ev.get("ts") or 0.0)
- if ts < cutoff:
- continue
- name = ev.get("name") or ""
- data = ev.get("data") or {}
- if name == "face_recognized":
- identity = (data.get("identity") or "").strip().lower()
- if identity and identity != "unknown" and identity not in seen:
- # Strong signal: known face seen recently.
- votes.append(SignalVote(
- signal=SIG_PERCEPTION,
- person_id=identity,
- weight=self._weights[SIG_PERCEPTION],
- evidence=f"face_recognized {ts:.0f}s",
- ))
- seen.add(identity)
- # face_detected (Layer 3 — no identity yet) does not produce a
- # vote; it's used for the "someone is here" gate when we
- # eventually add ASK escalation. Hook for Phase 1.5.
- return votes
-
- # ------------------------------------------------------------------
- # Combiner
- # ------------------------------------------------------------------
- def _combine(
- self, votes: list[SignalVote], *, now: float, # noqa: ARG002
- ) -> SpeakerResolution:
- if not votes:
- return self._fallback_resolution(votes=())
-
- # Aggregate per-person. Zero-weight votes (from a signal whose
- # weight has been turned off via config / tests) are dropped at
- # this stage so they don't pin the resolver to a phantom top-1.
- scores: dict[str, float] = {}
- signals_by_person: dict[str, list[SignalVote]] = {}
- for v in votes:
- if v.weight <= 0:
- continue
- scores[v.person_id] = scores.get(v.person_id, 0.0) + v.weight
- signals_by_person.setdefault(v.person_id, []).append(v)
-
- if not scores:
- return self._fallback_resolution(votes=tuple(votes))
-
- # Pick top-1 and runner-up.
- ranked = sorted(scores.items(), key=lambda kv: kv[1], reverse=True)
- top_id, top_score = ranked[0]
- runner_up_id: Optional[str] = None
- runner_up_score: float = 0.0
- if len(ranked) > 1:
- runner_up_id, runner_up_score = ranked[1]
-
- # Confidence is the top score, capped at 1.0. We deliberately
- # don't normalise across candidates because a clear top score
- # against a sea of zeros should still be high-confidence.
- confidence = min(1.0, top_score)
-
- # Look up display name; defensive against registry hiccups.
- display_name: Optional[str] = top_id
- if self._registry is not None:
- try:
- p = self._registry.get(top_id)
- if p is not None:
- display_name = p.display_name
- except Exception:
- log.debug(
- "speaker: registry.get raised in _combine", exc_info=True,
- )
-
- ask = (
- confidence < self.ask_threshold
- and SIG_SELF_ID not in {v.signal for v in votes}
- and SIG_STICKY not in {v.signal for v in votes}
- )
-
- return SpeakerResolution(
- person_id=top_id,
- addressee=display_name,
- confidence=round(confidence, 3),
- votes=tuple(signals_by_person[top_id]),
- ask_clarification=ask,
- runner_up_id=runner_up_id,
- runner_up_confidence=round(runner_up_score, 3),
- )
-
- def _fallback_resolution(
- self, *, votes: tuple[SignalVote, ...],
- ) -> SpeakerResolution:
- default = (
- self._registry.default_person
- if self._registry is not None else "_household"
- )
- return SpeakerResolution(
- person_id=None,
- addressee=default,
- confidence=0.0,
- votes=votes,
- ask_clarification=False,
- )
-
- # ------------------------------------------------------------------
- # Helpers
- # ------------------------------------------------------------------
- def _set_sticky(
- self, key: str, person_id: str, *, source: str, ts: float,
- ) -> None:
- with self._lock:
- self._sticky[key] = _StickyState(
- person_id=person_id, set_ts=ts, source=source,
- )
-
- @staticmethod
- def _sticky_key(
- channel: Optional[str], device_id: Optional[str],
- ) -> str:
- return f"{channel or ''}::{device_id or ''}"
-
- def _current_time_bucket(self, now: float) -> Optional[str]:
- local = datetime.fromtimestamp(now, tz=self._tz)
- minutes = local.hour * 60 + local.minute
- # Handle wraparound buckets first (night extends past midnight).
- for name, start, end in _TIME_BUCKETS:
- if end > 24 * 60:
- if minutes >= start or minutes < (end - 24 * 60):
- return name
- else:
- if start <= minutes < end:
- return name
- return None
-
- def _current_day_kind(self, now: float) -> str:
- """Returns 'weekdays' or 'weekends'. Public-ish so tests can
- inject. Aligns with the household.yaml `usual_times` keys."""
- local = datetime.fromtimestamp(now, tz=self._tz)
- # Mon=0 ... Sun=6
- return "weekends" if local.weekday() >= 5 else "weekdays"
-
- @staticmethod
- def _event_distance_minutes(ev: dict, *, now: float) -> Optional[float]:
- """Minutes between `now` and the event's start. Returns None if
- the event has no parseable start. The calendar cache already
- holds an `start_iso` field for parsed events."""
- iso = ev.get("start_iso") or ev.get("start") or ""
- if not iso:
- return None
- try:
- start = datetime.fromisoformat(str(iso).replace("Z", "+00:00"))
- except ValueError:
- return None
- return abs((start.timestamp() - now) / 60.0)
-
-
-# ---------------------------------------------------------------------------
-# Helpers
-# ---------------------------------------------------------------------------
-def _env_float(key: str, default: float) -> float:
- try:
- return float(os.environ.get(key, default))
- except (TypeError, ValueError):
- return default
-
-
-__all__ = [
- "SpeakerResolver",
- "SpeakerResolution",
- "SignalVote",
- "SIG_SELF_ID",
- "SIG_STICKY",
- "SIG_CALENDAR",
- "SIG_TIME_OF_DAY",
- "SIG_PERCEPTION",
- "SIG_VLM_MATCH",
-]
diff --git a/scripts/deploy-bridge.sh b/scripts/deploy-bridge.sh
deleted file mode 100755
index d21e35c..0000000
--- a/scripts/deploy-bridge.sh
+++ /dev/null
@@ -1,102 +0,0 @@
-#!/usr/bin/env bash
-# Deploy the bridge code (top-level bridge.py + bridge/ package) from this repo
-# to the host running zeroclaw-bridge. Designed for both manual ad-hoc use and
-# post-commit auto-sync routines.
-#
-# Why a script instead of inline routine logic: the prior cloud routine pushed
-# only bridge.py and missed new bridge/*.py modules — bit us with
-# bridge/privacy_signal.py during the Layer 6 deploy (ModuleNotFoundError on
-# restart). git ls-tree HEAD enumerates the deploy set from the repo's actual
-# tracked-file list, so any newly tracked module is auto-included.
-#
-# Usage:
-# BRIDGE_HOST=user@host bash scripts/deploy-bridge.sh
-#
-# Environment overrides:
-# BRIDGE_HOST SSH user@host running zeroclaw-bridge (required)
-# REMOTE_DIR Bridge install dir on the host (default: /root/zeroclaw-bridge)
-#
-# Requirements on the host: passwordless sudo for the SSH user.
-
-set -euo pipefail
-
-BRIDGE_HOST="${BRIDGE_HOST:?set BRIDGE_HOST=user@host}"
-REMOTE_DIR="${REMOTE_DIR:-/root/zeroclaw-bridge}"
-TS="$(date +%Y%m%d-%H%M%S)"
-LOCAL_TGZ="$(mktemp -t dotty-bridge.XXXXXX.tgz)"
-trap 'rm -f "$LOCAL_TGZ"' EXIT
-
-cd "$(git rev-parse --show-toplevel)"
-
-# 1. Enumerate deploy set from HEAD (tracked-only — auto-includes new modules,
-# skips __pycache__ / .venv / in-progress edits).
-mapfile -t FILES < <(git ls-tree -r --name-only HEAD bridge.py bridge/)
-if [[ ${#FILES[@]} -eq 0 ]]; then
- echo "ERROR: no tracked bridge files found at HEAD" >&2
- exit 1
-fi
-echo "Deploy set: ${#FILES[@]} files (HEAD $(git rev-parse --short HEAD))"
-
-# 2. SSH preflight — fail fast if creds / sudo broken.
-ssh -o BatchMode=yes -o ConnectTimeout=5 "$BRIDGE_HOST" sudo -n true \
- || { echo "ERROR: ssh+sudo preflight failed for $BRIDGE_HOST" >&2; exit 1; }
-
-# 3. Pre-deploy snapshot on the bridge host. Keep the last 3 deploy snapshots.
-# Prune pipeline runs under `sudo sh -c` because /root/ isn't readable
-# by a non-root SSH user without sudo — bare `ls /root/...` would
-# "Permission denied" and kill the script under set -e + pipefail.
-ssh "$BRIDGE_HOST" "
- set -euo pipefail
- sudo cp -a $REMOTE_DIR ${REMOTE_DIR}.bak-deploy-$TS
- sudo sh -c \"ls -1dt ${REMOTE_DIR}.bak-deploy-* 2>/dev/null | tail -n +4 | xargs -r rm -rf\" || true
-"
-
-# 4. Pack + ship via cat (avoids needing sftp-server / rsync on the host).
-tar -czf "$LOCAL_TGZ" "${FILES[@]}"
-cat "$LOCAL_TGZ" | ssh "$BRIDGE_HOST" "cat > /tmp/dotty-bridge.tgz"
-
-# 5. Extract under root, chmod the deployed paths only, restart, poll until
-# the new uvicorn prints "Application startup complete" or until 30 s
-# elapses. Bridge cold-start runs face_db / face_recognizer / perception
-# consumers / piper warm-up, typically 8–15 s — a fixed sleep races it.
-ssh "$BRIDGE_HOST" "
- set -euo pipefail
- sudo tar -xzf /tmp/dotty-bridge.tgz -C $REMOTE_DIR \
- --owner=root --group=root --no-same-owner
- sudo chmod -R u=rwX,go=rX $REMOTE_DIR/bridge.py $REMOTE_DIR/bridge
- rm -f /tmp/dotty-bridge.tgz
- sudo systemctl restart zeroclaw-bridge
- DEADLINE=\$((\$(date +%s) + 30))
- while [ \$(date +%s) -lt \$DEADLINE ]; do
- JOURNAL=\$(sudo journalctl -u zeroclaw-bridge --since '40 seconds ago' --no-pager)
- if echo \"\$JOURNAL\" | grep -q 'Traceback'; then
- echo 'ERROR: traceback in journal after restart' >&2
- echo \"\$JOURNAL\" | tail -40 >&2
- exit 1
- fi
- if echo \"\$JOURNAL\" | grep -q 'Application startup complete'; then
- break
- fi
- sleep 1
- done
- sudo journalctl -u zeroclaw-bridge --since '40 seconds ago' --no-pager \
- | grep -q 'Application startup complete' \
- || { echo 'ERROR: \"Application startup complete\" not seen within 30s' >&2; \
- sudo journalctl -u zeroclaw-bridge --since '40 seconds ago' --no-pager | tail -20 >&2; exit 1; }
- sudo systemctl is-active zeroclaw-bridge >/dev/null \
- || { echo 'ERROR: zeroclaw-bridge not active after startup' >&2; \
- sudo systemctl status zeroclaw-bridge --no-pager | tail -20 >&2; exit 1; }
-"
-
-# 6. md5 round-trip on the deploy set — belt-and-suspenders against silent
-# transport corruption. /root/ isn't readable by a non-root SSH user without sudo,
-# so the cd + md5sum runs under `sudo bash -c`.
-LOCAL_MD5="$(md5sum "${FILES[@]}" | sort -k2)"
-REMOTE_MD5="$(ssh "$BRIDGE_HOST" "sudo bash -c 'cd $REMOTE_DIR && md5sum $(printf '%q ' "${FILES[@]}")'" | sort -k2)"
-if [[ "$LOCAL_MD5" != "$REMOTE_MD5" ]]; then
- echo "ERROR: md5 mismatch after deploy" >&2
- diff <(echo "$LOCAL_MD5") <(echo "$REMOTE_MD5") >&2 || true
- exit 1
-fi
-
-echo "OK — deployed ${#FILES[@]} files, service active, md5s match"
diff --git a/scripts/install-bridge.sh b/scripts/install-bridge.sh
deleted file mode 100755
index 3d1253d..0000000
--- a/scripts/install-bridge.sh
+++ /dev/null
@@ -1,307 +0,0 @@
-#!/bin/bash
-# install-bridge.sh — Install zeroclaw-bridge on a Linux host with systemd.
-#
-# Usage:
-# sudo ./install-bridge.sh [OPTIONS]
-#
-# Options:
-# --bridge-dir DIR Install directory (default: /root/zeroclaw-bridge)
-# --zeroclaw-bin PATH Path to zeroclaw binary (default: /root/.cargo/bin/zeroclaw)
-# --port PORT Bridge listen port (default: 8080)
-# --dry-run Print what would happen without making changes
-# --help Show this help
-#
-# The script is idempotent — safe to re-run. It will:
-# 1. Verify prerequisites (Python 3.10+, pip, systemd, zeroclaw binary)
-# 2. Create the bridge directory and copy bridge.py into it
-# 3. Create a Python venv and install dependencies from bridge/requirements.txt
-# 4. Write and install a systemd service file
-# 5. Enable + start the service
-# 6. Health-check the running bridge
-#
-# Run from the repo root, or from any directory — the script locates the
-# repo-relative files (bridge.py, bridge/requirements.txt) from its own path.
-
-set -euo pipefail
-
-# ---------- resolve repo root from script location ----------
-SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
-REPO_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)"
-
-# ---------- defaults ----------
-BRIDGE_DIR="/root/zeroclaw-bridge"
-ZEROCLAW_BIN="/root/.cargo/bin/zeroclaw"
-PORT=8080
-DRY_RUN=false
-SERVICE_NAME="zeroclaw-bridge"
-SERVICE_FILE="/etc/systemd/system/${SERVICE_NAME}.service"
-
-# ---------- colors ----------
-RED='\033[0;31m'
-GREEN='\033[0;32m'
-YELLOW='\033[1;33m'
-BOLD='\033[1m'
-NC='\033[0m' # no color
-
-info() { printf "${GREEN}[INFO]${NC} %s\n" "$*"; }
-warn() { printf "${YELLOW}[WARN]${NC} %s\n" "$*"; }
-err() { printf "${RED}[ERR]${NC} %s\n" "$*" >&2; }
-step() { printf "\n${BOLD}==> %s${NC}\n" "$*"; }
-
-# ---------- usage ----------
-usage() {
- sed -n '2,/^$/{ s/^# //; s/^#//; p }' "$0"
- exit 0
-}
-
-# ---------- parse args ----------
-while [[ $# -gt 0 ]]; do
- case "$1" in
- --bridge-dir) BRIDGE_DIR="$2"; shift 2 ;;
- --zeroclaw-bin) ZEROCLAW_BIN="$2"; shift 2 ;;
- --port) PORT="$2"; shift 2 ;;
- --dry-run) DRY_RUN=true; shift ;;
- --help|-h) usage ;;
- *) err "Unknown option: $1"; usage ;;
- esac
-done
-
-# ---------- dry-run wrapper ----------
-run() {
- if $DRY_RUN; then
- info "[dry-run] $*"
- else
- "$@"
- fi
-}
-
-# ---------- prerequisite checks ----------
-step "Checking prerequisites"
-
-# Python 3.10+
-if ! command -v python3 &>/dev/null; then
- err "python3 not found. Install Python 3.10+ and retry."
- exit 1
-fi
-PY_VER="$(python3 -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")')"
-PY_MAJOR="${PY_VER%%.*}"
-PY_MINOR="${PY_VER##*.}"
-if (( PY_MAJOR < 3 )) || (( PY_MAJOR == 3 && PY_MINOR < 10 )); then
- err "Python ${PY_VER} found — need 3.10+."
- exit 1
-fi
-info "Python ${PY_VER} OK"
-
-# pip (via python3 -m pip)
-if ! python3 -m pip --version &>/dev/null; then
- err "pip not available (python3 -m pip failed). Install python3-pip and retry."
- exit 1
-fi
-info "pip OK"
-
-# venv module
-if ! python3 -c "import venv" &>/dev/null; then
- err "Python venv module not available. Install python3-venv and retry."
- exit 1
-fi
-info "venv module OK"
-
-# systemd
-if ! command -v systemctl &>/dev/null; then
- err "systemctl not found — systemd is required."
- exit 1
-fi
-info "systemd OK"
-
-# zeroclaw binary
-if [[ ! -x "${ZEROCLAW_BIN}" ]]; then
- err "zeroclaw binary not found or not executable at: ${ZEROCLAW_BIN}"
- err "Install ZeroClaw first, or pass --zeroclaw-bin /path/to/zeroclaw"
- exit 1
-fi
-info "zeroclaw binary OK (${ZEROCLAW_BIN})"
-
-# repo files — bridge.py imports textUtils from custom-providers/ via a
-# sys.path shim, and the `bridge` package via normal Python import. Both
-# trees ship to BRIDGE_DIR alongside bridge.py.
-for f in bridge.py bridge/requirements.txt custom-providers/textUtils.py bridge/__init__.py; do
- if [[ ! -e "${REPO_DIR}/${f}" ]]; then
- err "${f} not found at ${REPO_DIR}/${f} — run this script from the dotty-stackchan repo."
- exit 1
- fi
-done
-info "Repo files OK (${REPO_DIR})"
-
-# ---------- create bridge directory ----------
-step "Setting up bridge directory: ${BRIDGE_DIR}"
-
-run mkdir -p "${BRIDGE_DIR}"
-
-if $DRY_RUN; then
- info "[dry-run] cp ${REPO_DIR}/bridge.py -> ${BRIDGE_DIR}/bridge.py"
- info "[dry-run] cp -r ${REPO_DIR}/{custom-providers,bridge} -> ${BRIDGE_DIR}/"
-else
- cp "${REPO_DIR}/bridge.py" "${BRIDGE_DIR}/bridge.py"
- info "Copied bridge.py"
-
- # custom-providers/ holds textUtils.py + LLM/TTS provider modules that
- # bridge.py imports via a sys.path shim. bridge/ holds metrics,
- # dashboard, perception, etc. — bridge.py reaches into them via
- # `from bridge.X import ...`. Both are required to avoid runtime
- # ModuleNotFoundError (issue #13).
- rm -rf "${BRIDGE_DIR}/custom-providers" "${BRIDGE_DIR}/bridge"
- cp -r "${REPO_DIR}/custom-providers" "${BRIDGE_DIR}/custom-providers"
- cp -r "${REPO_DIR}/bridge" "${BRIDGE_DIR}/bridge"
- # CRITICAL: drop bridge/__init__.py so bridge/ acts as a PEP 420
- # namespace package. Without this, `import bridge` resolves to the
- # package (empty __init__) and uvicorn `bridge:app` fails with
- # `module 'bridge' has no attribute 'app'`. With it removed, `import
- # bridge` resolves to bridge.py (the FastAPI app) while
- # `from bridge.metrics import ...` still works.
- rm -f "${BRIDGE_DIR}/bridge/__init__.py"
- # __pycache__ bloat from the source tree; the venv will regenerate.
- find "${BRIDGE_DIR}/custom-providers" "${BRIDGE_DIR}/bridge" \
- -type d -name __pycache__ -exec rm -rf {} + 2>/dev/null || true
- info "Copied custom-providers/ and bridge/ (bridge/ as namespace pkg)"
-fi
-
-# ---------- create/update venv and install deps ----------
-step "Setting up Python venv and dependencies"
-
-VENV_DIR="${BRIDGE_DIR}/.venv"
-
-if [[ -d "${VENV_DIR}" ]]; then
- info "Venv already exists at ${VENV_DIR} — upgrading deps"
-else
- info "Creating venv at ${VENV_DIR}"
- run python3 -m venv "${VENV_DIR}"
-fi
-
-if $DRY_RUN; then
- info "[dry-run] ${VENV_DIR}/bin/pip install -r ${REPO_DIR}/bridge/requirements.txt"
-else
- "${VENV_DIR}/bin/pip" install --upgrade pip --quiet
- "${VENV_DIR}/bin/pip" install -r "${REPO_DIR}/bridge/requirements.txt" --quiet
- info "Dependencies installed"
-fi
-
-# ---------- install systemd service ----------
-step "Installing systemd service: ${SERVICE_NAME}"
-
-SERVICE_CONTENT="[Unit]
-Description=ZeroClaw HTTP Bridge for StackChan
-After=network.target
-Wants=network.target
-
-[Service]
-Type=simple
-User=root
-WorkingDirectory=${BRIDGE_DIR}
-# Vision/LLM API keys (issue #15) — leading '-' makes the file optional;
-# without it the bridge logs a clear ERROR on photo intents instead of
-# confabulating a description from an empty VLM response.
-EnvironmentFile=-${BRIDGE_DIR}/.env
-Environment=ZEROCLAW_BIN=${ZEROCLAW_BIN}
-Environment=PORT=${PORT}
-Environment=DOTTY_KID_MODE=true
-ExecStart=${VENV_DIR}/bin/uvicorn bridge:app --host 0.0.0.0 --port ${PORT}
-Restart=on-failure
-RestartSec=5
-StandardOutput=journal
-StandardError=journal
-
-[Install]
-WantedBy=multi-user.target"
-
-# ---------- create stub .env if absent (issue #15) ----------
-# Make the API-key drop-in obvious. systemd already EnvironmentFile=-'s it.
-ENV_FILE="${BRIDGE_DIR}/.env"
-ENV_STUB="# zeroclaw-bridge runtime env (issue #15). Loaded by systemd via
-# EnvironmentFile= in /etc/systemd/system/${SERVICE_NAME}.service. Fill in
-# at least OPENROUTER_API_KEY or photo (VLM) intents will fail with an
-# explicit 'camera offline' message instead of confabulating descriptions.
-# Mode 0600 — contains secrets. After editing:
-# sudo systemctl restart ${SERVICE_NAME}
-
-OPENROUTER_API_KEY=
-# Optional split if you use separate keys for the vision model:
-#VISION_API_KEY=
-#VLM_API_KEY=
-"
-if [[ -f "${ENV_FILE}" ]]; then
- info "Existing ${ENV_FILE} preserved (not overwriting)"
-else
- if $DRY_RUN; then
- info "[dry-run] Would create stub ${ENV_FILE} (mode 0600) with OPENROUTER_API_KEY placeholder"
- else
- printf '%s' "${ENV_STUB}" > "${ENV_FILE}"
- chmod 600 "${ENV_FILE}"
- info "Created stub ${ENV_FILE} (mode 0600) — fill in OPENROUTER_API_KEY before photo intents will work"
- fi
-fi
-
-if $DRY_RUN; then
- info "[dry-run] Would write ${SERVICE_FILE}:"
- printf '%s\n' "${SERVICE_CONTENT}"
-else
- printf '%s\n' "${SERVICE_CONTENT}" > "${SERVICE_FILE}"
- info "Wrote ${SERVICE_FILE}"
-fi
-
-# ---------- smoke test: bridge.py imports cleanly ----------
-# Catches missing-module errors (like #13) here, with a readable traceback,
-# instead of letting systemd crash-loop the service and burying the cause
-# under restart noise. Skipped on dry-run since the venv won't exist.
-if ! $DRY_RUN; then
- step "Import smoke test"
- if (cd "${BRIDGE_DIR}" && "${VENV_DIR}/bin/python" -c "import bridge" 2>&1); then
- info "bridge.py imports cleanly"
- else
- err "bridge.py failed to import — see traceback above."
- err "Fix the import error before retrying; the systemd service will"
- err "crash-loop with the same traceback if started in this state."
- exit 1
- fi
-fi
-
-# ---------- enable and start the service ----------
-step "Enabling and starting ${SERVICE_NAME}"
-
-run systemctl daemon-reload
-run systemctl enable "${SERVICE_NAME}"
-
-# Restart if already running, start if not — covers both fresh install and re-run.
-run systemctl restart "${SERVICE_NAME}"
-
-if ! $DRY_RUN; then
- # Give the service a moment to start
- sleep 2
- if systemctl is-active --quiet "${SERVICE_NAME}"; then
- info "${SERVICE_NAME} is running"
- else
- warn "${SERVICE_NAME} may not have started — check: journalctl -u ${SERVICE_NAME} -n 30"
- fi
-fi
-
-# ---------- health check ----------
-step "Health check: http://localhost:${PORT}/health"
-
-if $DRY_RUN; then
- info "[dry-run] curl -sf http://localhost:${PORT}/health"
-else
- # Give uvicorn a few seconds to bind
- sleep 3
- if curl -sf "http://localhost:${PORT}/health" -o /dev/null; then
- info "Health check passed"
- curl -s "http://localhost:${PORT}/health" | python3 -m json.tool 2>/dev/null || true
- else
- warn "Health check failed — the service may still be starting."
- warn "Check logs: journalctl -u ${SERVICE_NAME} -f"
- fi
-fi
-
-# ---------- done ----------
-step "Done"
-info "Bridge installed at ${BRIDGE_DIR}"
-info "Service: systemctl status ${SERVICE_NAME}"
-info "Logs: journalctl -u ${SERVICE_NAME} -f"
diff --git a/tests/test_bridge_dispatch.py b/tests/test_bridge_dispatch.py
deleted file mode 100644
index 9859f2b..0000000
--- a/tests/test_bridge_dispatch.py
+++ /dev/null
@@ -1,299 +0,0 @@
-"""Unit tests for bridge.purr_player dispatch logic.
-
-Pure unit — no network, no real filesystem, no bridge.py import.
-Verifies the dispatch contract for head_pet_started → purr audio and
-the cooldown / sound-suppression invariants.
-
-Sound-localiser dispatch tests (sound_event → head-turn) are deferred
-pending extraction of _perception_sound_turner from bridge.py.
-"""
-from __future__ import annotations
-
-import asyncio
-import sys
-import unittest
-from pathlib import Path
-from unittest.mock import MagicMock, patch
-
-sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
-
-from bridge.purr_player import dispatch_purr_audio, run_purr_consumer # noqa: E402
-
-
-# ---------------------------------------------------------------------------
-# Helpers
-# ---------------------------------------------------------------------------
-
-def _run(coro):
- """Run a coroutine on a fresh event loop and return the result."""
- loop = asyncio.new_event_loop()
- asyncio.set_event_loop(loop)
- try:
- return loop.run_until_complete(coro)
- finally:
- loop.close()
- asyncio.set_event_loop(None)
-
-
-def _fake_path(*, exists: bool = True) -> MagicMock:
- p = MagicMock(spec=Path)
- p.exists.return_value = exists
- p.__str__ = lambda self: "/fake/bridge/assets/purr.opus"
- return p
-
-
-# ---------------------------------------------------------------------------
-# dispatch_purr_audio
-# ---------------------------------------------------------------------------
-
-class DispatchPurrAudioTests(unittest.TestCase):
- """dispatch_purr_audio sends the correct HTTP request and handles failure."""
-
- def test_returns_false_when_no_host(self):
- result = _run(
- dispatch_purr_audio("device-1", purr_path=_fake_path(), xiaozhi_host="")
- )
- self.assertFalse(result)
-
- def test_returns_false_when_host_falls_back_to_empty_module_default(self):
- # xiaozhi_host=None falls back to module-level _XIAOZHI_HOST which is
- # "" in test environments (XIAOZHI_HOST not set).
- result = _run(
- dispatch_purr_audio("device-1", purr_path=_fake_path(), xiaozhi_host=None)
- )
- self.assertFalse(result)
-
- def test_returns_true_on_2xx(self):
- mock_resp = MagicMock()
- mock_resp.status_code = 200
- with patch("requests.post", return_value=mock_resp):
- result = _run(
- dispatch_purr_audio(
- "device-1",
- purr_path=_fake_path(),
- xiaozhi_host="192.0.2.1",
- xiaozhi_port=8003,
- )
- )
- self.assertTrue(result)
-
- def test_returns_false_on_4xx(self):
- mock_resp = MagicMock()
- mock_resp.status_code = 404
- mock_resp.text = "not found"
- with patch("requests.post", return_value=mock_resp):
- result = _run(
- dispatch_purr_audio(
- "device-1", purr_path=_fake_path(), xiaozhi_host="192.0.2.1"
- )
- )
- self.assertFalse(result)
-
- def test_returns_false_on_5xx(self):
- mock_resp = MagicMock()
- mock_resp.status_code = 503
- mock_resp.text = "unavailable"
- with patch("requests.post", return_value=mock_resp):
- result = _run(
- dispatch_purr_audio(
- "device-1", purr_path=_fake_path(), xiaozhi_host="192.0.2.1"
- )
- )
- self.assertFalse(result)
-
- def test_returns_false_on_network_error(self):
- with patch("requests.post", side_effect=ConnectionError("refused")):
- result = _run(
- dispatch_purr_audio(
- "device-1", purr_path=_fake_path(), xiaozhi_host="192.0.2.1"
- )
- )
- self.assertFalse(result)
-
- def test_posts_to_correct_url(self):
- mock_resp = MagicMock()
- mock_resp.status_code = 200
- with patch("requests.post", return_value=mock_resp) as mock_post:
- _run(
- dispatch_purr_audio(
- "dev-42",
- purr_path=_fake_path(),
- xiaozhi_host="10.0.0.1",
- xiaozhi_port=8003,
- )
- )
- url = mock_post.call_args.args[0]
- self.assertEqual(url, "http://10.0.0.1:8003/xiaozhi/admin/play-asset")
-
- def test_posts_device_id_in_payload(self):
- mock_resp = MagicMock()
- mock_resp.status_code = 200
- with patch("requests.post", return_value=mock_resp) as mock_post:
- _run(
- dispatch_purr_audio(
- "dev-42", purr_path=_fake_path(), xiaozhi_host="10.0.0.1"
- )
- )
- payload = mock_post.call_args.kwargs["json"]
- self.assertEqual(payload["device_id"], "dev-42")
-
- def test_posts_asset_path_in_payload(self):
- mock_resp = MagicMock()
- mock_resp.status_code = 200
- with patch("requests.post", return_value=mock_resp) as mock_post:
- _run(
- dispatch_purr_audio(
- "dev-42", purr_path=_fake_path(), xiaozhi_host="10.0.0.1"
- )
- )
- payload = mock_post.call_args.kwargs["json"]
- self.assertIn("asset", payload)
- self.assertIsInstance(payload["asset"], str)
-
-
-# ---------------------------------------------------------------------------
-# run_purr_consumer
-# ---------------------------------------------------------------------------
-
-class PurrConsumerTests(unittest.TestCase):
- """run_purr_consumer dispatches purr on head_pet_started with cooldown."""
-
- def setUp(self) -> None:
- self.loop = asyncio.new_event_loop()
- asyncio.set_event_loop(self.loop)
-
- def tearDown(self) -> None:
- self.loop.close()
- asyncio.set_event_loop(None)
-
- async def _drain(
- self,
- events: list,
- *,
- cooldown_sec: float = 5.0,
- duration_sec: float = 2.0,
- state: dict | None = None,
- ) -> tuple[list[str], dict]:
- dispatches: list[str] = []
- if state is None:
- state = {}
- q: asyncio.Queue = asyncio.Queue()
- for ev in events:
- q.put_nowait(ev)
-
- async def capture(device_id: str) -> bool:
- dispatches.append(device_id)
- return True
-
- task = asyncio.create_task(
- run_purr_consumer(
- lambda: q,
- state,
- cooldown_sec=cooldown_sec,
- duration_sec=duration_sec,
- dispatch_fn=capture,
- )
- )
- await asyncio.sleep(0.05)
- task.cancel()
- try:
- await task
- except asyncio.CancelledError:
- pass
- return dispatches, state
-
- def test_head_pet_started_triggers_dispatch(self):
- dispatches, _ = self.loop.run_until_complete(
- self._drain(
- [{"name": "head_pet_started", "device_id": "dev-1", "ts": 1000.0}]
- )
- )
- self.assertEqual(dispatches, ["dev-1"])
-
- def test_cooldown_blocks_repeat_within_window(self):
- events = [
- {"name": "head_pet_started", "device_id": "dev-1", "ts": 1000.0},
- {"name": "head_pet_started", "device_id": "dev-1", "ts": 1003.0},
- ]
- dispatches, _ = self.loop.run_until_complete(
- self._drain(events, cooldown_sec=5.0)
- )
- self.assertEqual(len(dispatches), 1)
-
- def test_cooldown_allows_second_after_window(self):
- events = [
- {"name": "head_pet_started", "device_id": "dev-1", "ts": 1000.0},
- {"name": "head_pet_started", "device_id": "dev-1", "ts": 1006.0},
- ]
- dispatches, _ = self.loop.run_until_complete(
- self._drain(events, cooldown_sec=5.0)
- )
- self.assertEqual(len(dispatches), 2)
-
- def test_ignores_non_pet_event_types(self):
- events = [
- {"name": "sound_event", "device_id": "dev-1", "ts": 1000.0},
- {"name": "face_detected", "device_id": "dev-1", "ts": 1001.0},
- {"name": "face_lost", "device_id": "dev-1", "ts": 1002.0},
- ]
- dispatches, _ = self.loop.run_until_complete(self._drain(events))
- self.assertEqual(dispatches, [])
-
- def test_ignores_blank_device_id(self):
- dispatches, _ = self.loop.run_until_complete(
- self._drain(
- [{"name": "head_pet_started", "device_id": "", "ts": 1000.0}]
- )
- )
- self.assertEqual(dispatches, [])
-
- def test_ignores_unknown_device_id(self):
- dispatches, _ = self.loop.run_until_complete(
- self._drain(
- [{"name": "head_pet_started", "device_id": "unknown", "ts": 1000.0}]
- )
- )
- self.assertEqual(dispatches, [])
-
- def test_last_purr_t_recorded_in_state(self):
- _, state = self.loop.run_until_complete(
- self._drain(
- [{"name": "head_pet_started", "device_id": "dev-1", "ts": 100.0}]
- )
- )
- self.assertAlmostEqual(state["dev-1"]["last_purr_t"], 100.0)
-
- def test_last_chat_t_extended_for_sound_suppression(self):
- """last_chat_t must equal ts + duration_sec so the sound-localiser
- skips head-turn commands while the purr plays."""
- _, state = self.loop.run_until_complete(
- self._drain(
- [{"name": "head_pet_started", "device_id": "dev-1", "ts": 100.0}],
- duration_sec=2.0,
- )
- )
- self.assertAlmostEqual(state["dev-1"]["last_chat_t"], 102.0, delta=0.01)
-
- def test_separate_devices_have_independent_cooldowns(self):
- events = [
- {"name": "head_pet_started", "device_id": "dev-1", "ts": 1000.0},
- {"name": "head_pet_started", "device_id": "dev-2", "ts": 1001.0},
- ]
- dispatches, _ = self.loop.run_until_complete(
- self._drain(events, cooldown_sec=5.0)
- )
- self.assertCountEqual(dispatches, ["dev-1", "dev-2"])
-
- def test_zero_cooldown_allows_immediate_repeat(self):
- events = [
- {"name": "head_pet_started", "device_id": "dev-1", "ts": 1000.0},
- {"name": "head_pet_started", "device_id": "dev-1", "ts": 1000.1},
- ]
- dispatches, _ = self.loop.run_until_complete(
- self._drain(events, cooldown_sec=0.0)
- )
- self.assertEqual(len(dispatches), 2)
-
-
-if __name__ == "__main__":
- unittest.main()
diff --git a/tests/test_bridge_perception_state.py b/tests/test_bridge_perception_state.py
deleted file mode 100644
index 130a577..0000000
--- a/tests/test_bridge_perception_state.py
+++ /dev/null
@@ -1,104 +0,0 @@
-"""Unit tests for /api/perception/state staleness annotation.
-
-Pure unit — exercises the _annotate helper logic extracted from the
-perception_state endpoint without importing bridge.py or starting FastAPI.
-"""
-from __future__ import annotations
-
-import math
-import unittest
-
-
-_PERCEPTION_STALE_THRESHOLD_S: float = 30.0
-
-
-def _annotate(raw: dict, *, now: float) -> dict:
- """Mirror of the _annotate closure inside perception_state()."""
- out = dict(raw)
- last_t = out.get("last_event_t")
- if last_t is None:
- age = float("inf")
- else:
- age = max(0.0, now - last_t)
- out["sensor_age_s"] = age
- out["sensor_stale"] = age > _PERCEPTION_STALE_THRESHOLD_S
- return out
-
-
-class PerceptionStateAnnotationTests(unittest.TestCase):
-
- def test_fresh_event_not_stale(self):
- now = 1000.0
- state = {"last_event_t": now - 1.0, "face_present": True}
- result = _annotate(state, now=now)
- self.assertFalse(result["sensor_stale"])
- self.assertAlmostEqual(result["sensor_age_s"], 1.0)
-
- def test_event_exactly_at_threshold_not_stale(self):
- """Age == threshold is NOT stale (boundary: > not >=)."""
- now = 1000.0
- state = {"last_event_t": now - _PERCEPTION_STALE_THRESHOLD_S}
- result = _annotate(state, now=now)
- self.assertFalse(result["sensor_stale"])
- self.assertAlmostEqual(result["sensor_age_s"], _PERCEPTION_STALE_THRESHOLD_S)
-
- def test_event_one_second_past_threshold_is_stale(self):
- now = 1000.0
- state = {"last_event_t": now - (_PERCEPTION_STALE_THRESHOLD_S + 1.0)}
- result = _annotate(state, now=now)
- self.assertTrue(result["sensor_stale"])
- self.assertAlmostEqual(
- result["sensor_age_s"], _PERCEPTION_STALE_THRESHOLD_S + 1.0
- )
-
- def test_missing_last_event_t_is_stale(self):
- state = {"face_present": False}
- result = _annotate(state, now=1000.0)
- self.assertTrue(result["sensor_stale"])
- self.assertTrue(math.isinf(result["sensor_age_s"]))
-
- def test_empty_dict_is_stale(self):
- result = _annotate({}, now=1000.0)
- self.assertTrue(result["sensor_stale"])
- self.assertTrue(math.isinf(result["sensor_age_s"]))
-
- def test_original_dict_not_mutated(self):
- state = {"last_event_t": 990.0}
- original_keys = set(state.keys())
- _annotate(state, now=1000.0)
- self.assertEqual(set(state.keys()), original_keys)
-
- def test_existing_fields_preserved(self):
- state = {
- "last_event_t": 999.0,
- "face_present": True,
- "last_face_t": 999.0,
- "last_event_name": "face_detected",
- }
- result = _annotate(state, now=1000.0)
- self.assertEqual(result["face_present"], True)
- self.assertEqual(result["last_face_t"], 999.0)
- self.assertEqual(result["last_event_name"], "face_detected")
-
- def test_age_clamped_to_zero_when_future_timestamp(self):
- now = 1000.0
- state = {"last_event_t": now + 5.0}
- result = _annotate(state, now=now)
- self.assertEqual(result["sensor_age_s"], 0.0)
- self.assertFalse(result["sensor_stale"])
-
- def test_30_seconds_minus_epsilon_not_stale(self):
- now = 1000.0
- state = {"last_event_t": now - 29.999}
- result = _annotate(state, now=now)
- self.assertFalse(result["sensor_stale"])
-
- def test_30_seconds_plus_epsilon_is_stale(self):
- now = 1000.0
- state = {"last_event_t": now - 30.001}
- result = _annotate(state, now=now)
- self.assertTrue(result["sensor_stale"])
-
-
-if __name__ == "__main__":
- unittest.main()
diff --git a/tests/test_bridge_routes.py b/tests/test_bridge_routes.py
index 8696149..9d59640 100644
--- a/tests/test_bridge_routes.py
+++ b/tests/test_bridge_routes.py
@@ -1,26 +1,20 @@
-"""Boundary tests for bridge.py's FastAPI routes — phase 1.
+"""Boundary tests for bridge.py's FastAPI routes.
-Background: bridge.py exposes 13 HTTP routes but only /health was
-exercised through unit tests and none through the actual HTTP boundary.
-This file lands TestClient-based tests for the 6 lowest-dependency
-routes — health, perception event/state, calendar today, voice
-memory_log, voice remember. The remaining 7 (vision, audio, message,
-voice escalate, SSE feed, message/stream) need ACPClient + LLM
-fakes and will land in follow-up commits.
+Post-#111 surface: bridge.py is the dashboard host. Its voice + perception
+endpoints were ripped in #113; the only HTTP boundary left worth testing
+at the bridge level is `/health` (the dashboard's /ui/* router is covered
+by tests/test_dashboard_csrf.py).
Import wiring:
- bridge.py is the FastAPI app; the `bridge` package also exists
(bridge/__init__.py for submodules), so `import bridge` resolves
to the package. We load bridge.py explicitly via importlib under
the module name `bridge_app` to avoid the collision.
- - The app's lifespan spawns ~11 perception consumers + an ACP
- subprocess + a calendar poll. None of that is needed for route
- boundary tests, so we replace `app.router.lifespan_context` with
- a no-op async context manager BEFORE constructing TestClient.
- - `acp` (module-level ACPClient) is stubbed at the attribute level
- so /health reads sane values.
- - `_refresh_caches` is patched to a no-op so /api/calendar/today
- doesn't hit the network.
+ - The slim post-#111 app no longer spawns the ACP subprocess /
+ perception consumers / calendar poll, so the heavy lifespan
+ neutralisation that earlier revisions of this file performed is
+ no longer required. A no-op lifespan is still installed for
+ defence-in-depth.
"""
from __future__ import annotations
@@ -31,33 +25,18 @@
import unittest
from contextlib import asynccontextmanager
from pathlib import Path
-from unittest.mock import AsyncMock, MagicMock, patch
# ---------------------------------------------------------------------------
-# Import bridge.py as `bridge_app` and neutralise its heavy lifespan.
+# Import bridge.py as `bridge_app`.
# ---------------------------------------------------------------------------
# State files (kid-mode + smart-mode) default to /root/zeroclaw-bridge/state/...
-# which the CI runner can neither read (/root is 700) nor write. Python 3.12's
-# Path.exists() raises PermissionError on stat failure (3.13+ swallows), so
-# even reading the toggle at module-import time blows up. Redirect both to a
-# writable temp dir before import.
+# which the CI runner can neither read (/root is 700) nor write. Redirect both
+# to a writable temp dir before import.
_state_dir = Path(tempfile.mkdtemp(prefix="dotty-bridge-test-state-"))
os.environ.setdefault("DOTTY_KID_MODE_STATE", str(_state_dir / "kid-mode"))
os.environ.setdefault("DOTTY_SMART_MODE_STATE", str(_state_dir / "smart-mode"))
-# CONVO_LOG_DIR defaults to /root/zeroclaw-bridge/logs — same problem. Used
-# by /api/message and /api/message/stream via _convo_log.log_turn.
-os.environ.setdefault("CONVO_LOG_DIR", str(_state_dir / "logs"))
-
-# Env-var skips for background loops that the lifespan would otherwise
-# spawn. These have no effect at module-import time but keep the lifespan
-# tidy in case it ever does fire during a test run.
-os.environ.setdefault("IDLE_PHOTOGRAPHER_ENABLED", "0")
-os.environ.setdefault("DREAMER_ENABLED", "0")
-os.environ.setdefault("DANCE_REFLECTOR_ENABLED", "0")
-os.environ.setdefault("CALENDAR_IDS", "") # short-circuits _fetch_calendar_events
-os.environ.setdefault("ZEROCLAW_BIN", "/bin/true") # never spawned
_repo_root = Path(__file__).resolve().parents[1]
_spec = importlib.util.spec_from_file_location(
@@ -71,618 +50,44 @@
@asynccontextmanager
async def _noop_lifespan(_app):
- """No-op lifespan that bypasses ACP spawn, perception consumers,
- proactive greeter, and the calendar poll loop."""
+ """No-op lifespan. The post-#111 bridge has no background spawn,
+ but keep this in place so future additions can't sneak network /
+ subprocess work into a unit-test import path."""
yield
bridge_app.app.router.lifespan_context = _noop_lifespan
-# ---------------------------------------------------------------------------
-# Per-test acp stub + cache reset helpers
-# ---------------------------------------------------------------------------
-
-class _StubProc:
- """Stand-in for asyncio subprocess. /health reads only `returncode`."""
- def __init__(self, alive: bool = True):
- self.returncode = None if alive else 1
-
-
-def _install_acp_stub(*, alive: bool = True, sid: str | None = None, turns: int = 0):
- """Replace the module-level `acp` with a stub bare enough for /health
- and the voice memory-write endpoints to read sensible values."""
- acp_stub = MagicMock()
- acp_stub._proc = _StubProc(alive=alive) if alive is not None else None
- acp_stub._sid = sid
- acp_stub._sid_turns = turns
- bridge_app.acp = acp_stub
- return acp_stub
-
-
-def _reset_perception_state():
- bridge_app._perception_state.clear()
-
-
-# Module-level TestClient — cheap to reuse; tests reset module state
-# (_perception_state, acp stub) in setUp instead of rebuilding the client.
from fastapi.testclient import TestClient # noqa: E402
client = TestClient(bridge_app.app)
-# Separate client for the 500-error tests. TestClient defaults to
-# raise_server_exceptions=True (handler exceptions propagate to the
-# caller), which is great for debugging but defeats assertions about
-# the HTTP status of an exception path. This variant returns 500
-# the same way a real client would observe.
-client_no_raise = TestClient(bridge_app.app, raise_server_exceptions=False)
-
# ---------------------------------------------------------------------------
# /health
# ---------------------------------------------------------------------------
class HealthTests(unittest.TestCase):
- def test_alive_acp_no_session(self):
- _install_acp_stub(alive=True, sid=None, turns=0)
- r = client.get("/health")
- self.assertEqual(r.status_code, 200)
- body = r.json()
- self.assertEqual(body["status"], "ok")
- self.assertEqual(body["service"], "zeroclaw-bridge")
- self.assertTrue(body["acp_running"])
- self.assertFalse(body["cached_session"])
- self.assertEqual(body["session_turns"], 0)
+ """The post-#111 /health is a minimal liveness probe — `{status, service}`.
+ The ACP / session fields the pre-#36 surface carried are gone with the
+ rest of the ZeroClaw path."""
- def test_dead_acp(self):
- _install_acp_stub(alive=False, sid=None, turns=0)
+ def test_returns_ok_status(self):
r = client.get("/health")
self.assertEqual(r.status_code, 200)
- self.assertFalse(r.json()["acp_running"])
+ self.assertEqual(r.json()["status"], "ok")
- def test_cached_session_reported(self):
- _install_acp_stub(alive=True, sid="sess-42", turns=7)
+ def test_reports_service_name(self):
body = client.get("/health").json()
- self.assertTrue(body["cached_session"])
- self.assertEqual(body["session_turns"], 7)
-
-
-# ---------------------------------------------------------------------------
-# /api/perception/event + /api/perception/state
-# ---------------------------------------------------------------------------
-
-class PerceptionEventStateTests(unittest.TestCase):
- def setUp(self):
- _install_acp_stub()
- _reset_perception_state()
-
- def test_post_event_returns_204_and_updates_state(self):
- r = client.post(
- "/api/perception/event",
- json={
- "device_id": "dotty-aa:bb",
- "name": "face_detected",
- "data": {},
- },
- )
- self.assertEqual(r.status_code, 204)
- # State must reflect the event for this device.
- state = client.get("/api/perception/state").json()
- self.assertIn("dotty-aa:bb", state)
-
- def test_state_with_device_id_param_returns_single_device(self):
- client.post("/api/perception/event", json={
- "device_id": "alpha", "name": "face_detected", "data": {},
- })
- client.post("/api/perception/event", json={
- "device_id": "beta", "name": "face_detected", "data": {},
- })
- body = client.get("/api/perception/state?device_id=alpha").json()
- # Per-device query: only `alpha` is keyed in the response.
- self.assertEqual(set(body.keys()), {"alpha"})
-
- def test_state_annotates_sensor_age_and_staleness(self):
- client.post("/api/perception/event", json={
- "device_id": "x", "name": "face_detected", "data": {},
- })
- entry = client.get("/api/perception/state").json()["x"]
- # Annotations always present per the route docstring.
- self.assertIn("sensor_age_s", entry)
- self.assertIn("sensor_stale", entry)
- self.assertFalse(entry["sensor_stale"]) # fresh event
-
- def test_state_missing_device_returns_stale(self):
- body = client.get("/api/perception/state?device_id=ghost").json()
- ghost = body["ghost"]
- # sensor_age_s is float('inf') over Python but JSON-encodes to None
- # (FastAPI uses allow_nan=False, then coerces). The actionable signal
- # is sensor_stale, which must be True for a never-seen device.
- self.assertTrue(ghost["sensor_stale"])
-
- def test_post_event_validation_missing_name(self):
- # `name` is required (no default).
- r = client.post(
- "/api/perception/event",
- json={"device_id": "x", "data": {}},
- )
- self.assertEqual(r.status_code, 422)
-
-
-# ---------------------------------------------------------------------------
-# /api/calendar/today
-# ---------------------------------------------------------------------------
-
-class CalendarTodayTests(unittest.TestCase):
- """Calendar route reads _calendar_cache after a lazy refresh. We stub
- _refresh_caches to a no-op so no network is hit, then mutate the
- cache directly to drive each scenario."""
-
- def setUp(self):
- _install_acp_stub()
- # Reset cache to a known state.
- bridge_app._calendar_cache.update({
- "date": "2026-05-17",
- "fetched": 1715900000.0,
- "consecutive_failures": 0,
- "events": [],
- })
-
- def test_empty_cache(self):
- with patch.object(bridge_app, "_refresh_caches",
- new=AsyncMock(return_value=None)):
- r = client.get("/api/calendar/today")
- self.assertEqual(r.status_code, 200)
- body = r.json()
- self.assertTrue(body["ok"])
- self.assertEqual(body["events"], [])
- self.assertEqual(body["count"], 0)
- self.assertEqual(body["date"], "2026-05-17")
- self.assertIsNone(body["person"])
- self.assertTrue(body["include_household"])
-
- def test_person_filter_passed_through(self):
- with patch.object(bridge_app, "_refresh_caches",
- new=AsyncMock(return_value=None)):
- r = client.get(
- "/api/calendar/today?person=alex&include_household=false",
- )
- self.assertEqual(r.status_code, 200)
- body = r.json()
- self.assertEqual(body["person"], "alex")
- self.assertFalse(body["include_household"])
-
- def test_refresh_failure_propagates_500(self):
- with patch.object(
- bridge_app, "_refresh_caches",
- new=AsyncMock(side_effect=RuntimeError("calendar API down")),
- ):
- r = client_no_raise.get("/api/calendar/today")
- self.assertEqual(r.status_code, 500)
-
-
-# ---------------------------------------------------------------------------
-# /api/voice/memory_log + /api/voice/remember
-# ---------------------------------------------------------------------------
-
-class VoiceMemoryLogTests(unittest.TestCase):
- """Both endpoints fire-and-forget via _spawn(asyncio.to_thread(...)).
- Patch the blocking store fn to a no-op so no disk writes happen, and
- assert it was invoked with the expected args."""
-
- def setUp(self):
- _install_acp_stub()
-
- def test_memory_log_204_and_calls_store(self):
- with patch.object(bridge_app, "_voice_memory_store_blocking") as mock_store:
- r = client.post(
- "/api/voice/memory_log",
- json={
- "user": "hello",
- "assistant": "hi there",
- "session_id": "s-1",
- },
- )
- self.assertEqual(r.status_code, 204)
- self.assertEqual(mock_store.call_count, 1)
- kwargs = mock_store.call_args.kwargs
- self.assertEqual(kwargs["namespace"], "voice")
- self.assertEqual(kwargs["category"], "conversation")
- self.assertEqual(kwargs["session_id"], "s-1")
- self.assertIn("hello", kwargs["content"])
- self.assertIn("hi there", kwargs["content"])
-
- def test_memory_log_skips_empty_pair(self):
- with patch.object(bridge_app, "_voice_memory_store_blocking") as mock_store:
- r = client.post(
- "/api/voice/memory_log",
- json={"user": "", "assistant": "", "session_id": None},
- )
- self.assertEqual(r.status_code, 204)
- mock_store.assert_not_called()
-
- def test_memory_log_truncates_long_inputs(self):
- long_user = "u" * 2000
- long_assistant = "a" * 5000
- with patch.object(bridge_app, "_voice_memory_store_blocking") as mock_store:
- client.post("/api/voice/memory_log", json={
- "user": long_user, "assistant": long_assistant,
- })
- kwargs = mock_store.call_args.kwargs
- # Per the route: user is truncated to 500 chars, assistant to 1000.
- # Verify by extracting the segments from the "user: X | assistant: Y" envelope.
- content = kwargs["content"]
- user_seg = content.split(" | assistant: ", 1)[0].removeprefix("user: ")
- assistant_seg = content.split(" | assistant: ", 1)[1]
- self.assertEqual(len(user_seg), 500)
- self.assertEqual(len(assistant_seg), 1000)
-
-
-class VoiceRememberTests(unittest.TestCase):
- def setUp(self):
- _install_acp_stub()
-
- def test_remember_204_and_calls_store_with_core_category(self):
- with patch.object(bridge_app, "_voice_memory_store_blocking") as mock_store:
- r = client.post(
- "/api/voice/remember",
- json={"fact": "birthday is March 4", "session_id": "s-1"},
- )
- self.assertEqual(r.status_code, 204)
- self.assertEqual(mock_store.call_count, 1)
- kwargs = mock_store.call_args.kwargs
- self.assertEqual(kwargs["category"], "core")
- self.assertEqual(kwargs["namespace"], "voice")
- self.assertEqual(kwargs["content"], "birthday is March 4")
- # Higher importance than conversation logs per the route.
- self.assertGreater(kwargs["importance"], 0.5)
-
- def test_remember_skips_empty_fact(self):
- with patch.object(bridge_app, "_voice_memory_store_blocking") as mock_store:
- r = client.post("/api/voice/remember", json={"fact": " "})
- self.assertEqual(r.status_code, 204)
- mock_store.assert_not_called()
-
- def test_remember_truncates_to_300_chars(self):
- with patch.object(bridge_app, "_voice_memory_store_blocking") as mock_store:
- client.post(
- "/api/voice/remember",
- json={"fact": "x" * 1000},
- )
- kwargs = mock_store.call_args.kwargs
- self.assertEqual(len(kwargs["content"]), 300)
-
-
-# ---------------------------------------------------------------------------
-# /api/voice/escalate — Tier-2 tool dispatcher
-# ---------------------------------------------------------------------------
-
-class VoiceEscalateTests(unittest.TestCase):
- def setUp(self):
- _install_acp_stub()
-
- def test_unknown_tool_returns_friendly_string(self):
- r = client.post("/api/voice/escalate", json={
- "tool": "not_a_tool", "args": {}, "session_id": "s",
- })
- self.assertEqual(r.status_code, 200)
- body = r.json()
- self.assertEqual(body["result"], "(unknown tool: not_a_tool)")
-
- def test_known_tool_result_round_trips(self):
- async def fake_handler(args, session_id):
- return f"echo:{args.get('query', '')}|sid={session_id}"
-
- with patch.dict(
- bridge_app._VOICE_TOOLS,
- {"memory_lookup": fake_handler},
- ):
- r = client.post("/api/voice/escalate", json={
- "tool": "memory_lookup",
- "args": {"query": "birthday"},
- "session_id": "sess-1",
- })
- self.assertEqual(r.status_code, 200)
- self.assertEqual(r.json()["result"], "echo:birthday|sid=sess-1")
-
- def test_handler_exception_yields_failed_result(self):
- async def boom(args, session_id):
- raise RuntimeError("tool exploded")
-
- with patch.dict(
- bridge_app._VOICE_TOOLS,
- {"think_hard": boom},
- ):
- r = client.post("/api/voice/escalate", json={
- "tool": "think_hard", "args": {"question": "?"},
- })
- self.assertEqual(r.status_code, 200)
- # Handler exception is swallowed; client always gets 200 + result.
- self.assertEqual(r.json()["result"], "(think_hard failed)")
-
- def test_validation_missing_tool(self):
- r = client.post("/api/voice/escalate", json={"args": {}})
- self.assertEqual(r.status_code, 422)
+ self.assertEqual(body["service"], "dotty-bridge")
-
-# ---------------------------------------------------------------------------
-# /api/vision/explain + /api/audio/explain — VLM / ASR description routes
-# ---------------------------------------------------------------------------
-
-class VisionExplainTests(unittest.TestCase):
- """Just the simple description path. The room_view roster branch has
- its own substantial state-machine + cooldown logic and warrants its
- own dedicated test module."""
-
- def setUp(self):
- _install_acp_stub()
- bridge_app._vision_cache.clear()
-
- def test_returns_description_and_caches_it(self):
- with patch.object(
- bridge_app, "_call_vision_api",
- return_value="I see a small black robot on a desk.",
- ):
- r = client.post(
- "/api/vision/explain",
- headers={"device-id": "dotty-x1"},
- files={"file": ("photo.jpg", b"\xff\xd8\xff\xe0fake", "image/jpeg")},
- data={"question": "What's in the photo?"},
- )
- self.assertEqual(r.status_code, 200)
- body = r.json()
- self.assertIn("black robot", body["description"])
- # Cache populated for the device.
- self.assertIn("dotty-x1", bridge_app._vision_cache)
-
-
-class AudioExplainTests(unittest.TestCase):
- def setUp(self):
- _install_acp_stub()
- bridge_app._audio_cache.clear()
-
- def test_returns_caption_and_caches_it(self):
- with patch.object(
- bridge_app, "_call_audio_caption_api",
- return_value="I hear footsteps and a door closing.",
- ):
- r = client.post(
- "/api/audio/explain",
- headers={"device-id": "dotty-x1"},
- files={"file": ("clip.wav", b"RIFFfake", "audio/wav")},
- data={"question": "What's that sound?"},
- )
- self.assertEqual(r.status_code, 200)
- self.assertIn("footsteps", r.json()["description"])
- self.assertIn("dotty-x1", bridge_app._audio_cache)
-
- def test_caption_failure_returns_fallback_string(self):
- # _call_audio_caption_api catches its own exceptions and returns a
- # human-friendly fallback string — the route still 200s.
- with patch.object(
- bridge_app, "_call_audio_caption_api",
- return_value="I couldn't quite hear that clearly.",
- ):
- r = client.post(
- "/api/audio/explain",
- files={"file": ("clip.wav", b"RIFFfake", "audio/wav")},
- )
- self.assertEqual(r.status_code, 200)
- self.assertIn("couldn't quite hear", r.json()["description"])
-
-
-# ---------------------------------------------------------------------------
-# /api/message — the central voice turn
-# ---------------------------------------------------------------------------
-
-class MessageTests(unittest.TestCase):
- """Patch acp.prompt to control the LLM response, _refresh_caches to
- skip network. /api/message wraps the response in emoji-prefix +
- sentence-truncation, so we assert on the shape rather than exact text."""
-
- def setUp(self):
- _install_acp_stub()
- # acp.prompt is awaited; AsyncMock is required.
- bridge_app.acp.prompt = AsyncMock(return_value="😊 Hi there friend.")
- bridge_app.acp._last_phases = None
-
- def test_happy_path_returns_response_and_session_id(self):
- with patch.object(bridge_app, "_refresh_caches",
- new=AsyncMock(return_value=None)):
- r = client.post("/api/message", json={
- "content": "hello",
- "channel": "dotty",
- "session_id": "s-abc",
- })
- self.assertEqual(r.status_code, 200)
- body = r.json()
- self.assertEqual(body["session_id"], "s-abc")
- self.assertIn("Hi there", body["response"])
- # Emoji prefix preserved by the response pipeline.
- self.assertTrue(body["response"].lstrip().startswith("😊"))
-
- def test_missing_session_id_gets_auto_uuid(self):
- with patch.object(bridge_app, "_refresh_caches",
- new=AsyncMock(return_value=None)):
- r = client.post("/api/message", json={"content": "hi"})
- self.assertEqual(r.status_code, 200)
- sid = r.json()["session_id"]
- # UUIDs are 36 chars with hyphens.
- self.assertEqual(len(sid), 36)
- self.assertEqual(sid.count("-"), 4)
-
- def test_acp_timeout_yields_fallback_response(self):
- bridge_app.acp.prompt = AsyncMock(side_effect=__import__("asyncio").TimeoutError())
- with patch.object(bridge_app, "_refresh_caches",
- new=AsyncMock(return_value=None)):
- r = client.post("/api/message", json={"content": "hi"})
- self.assertEqual(r.status_code, 200)
- body = r.json()
- self.assertIn("thinking too slowly", body["response"])
-
- def test_acp_exception_yields_generic_fallback(self):
- bridge_app.acp.prompt = AsyncMock(side_effect=RuntimeError("boom"))
- with patch.object(bridge_app, "_refresh_caches",
- new=AsyncMock(return_value=None)):
- r = client.post("/api/message", json={"content": "hi"})
- self.assertEqual(r.status_code, 200)
- self.assertIn("Something went wrong", r.json()["response"])
-
- def test_missing_emoji_prefix_gets_one_added(self):
- # When the LLM forgets its emoji, the bridge prepends FALLBACK_EMOJI.
- bridge_app.acp.prompt = AsyncMock(return_value="Hi without emoji.")
- with patch.object(bridge_app, "_refresh_caches",
- new=AsyncMock(return_value=None)):
- r = client.post("/api/message", json={"content": "hi"})
- body = r.json()
- first_char = body["response"].lstrip()[0]
- # Must be one of the nine allowed emoji (FALLBACK_EMOJI is 😐).
- self.assertIn(first_char, bridge_app.ALLOWED_EMOJIS)
-
-
-# ---------------------------------------------------------------------------
-# /api/message/stream — NDJSON streaming variant
-# ---------------------------------------------------------------------------
-
-class MessageStreamTests(unittest.TestCase):
- """Stream emits one JSON line per chunk_cb invocation, then a `final`
- line. When acp.prompt returns immediately without calling chunk_cb
- (the simplest path), the route emits the full text as a single chunk
- + the final line."""
-
- def setUp(self):
- _install_acp_stub()
- bridge_app.acp.prompt = AsyncMock(return_value="😊 Hello.")
- bridge_app.acp._last_phases = None
-
- def _read_ndjson(self, raw: bytes) -> list[dict]:
- import json as _json
- return [_json.loads(line) for line in raw.decode().splitlines() if line.strip()]
-
- def test_ndjson_emits_chunk_then_final(self):
- with patch.object(bridge_app, "_refresh_caches",
- new=AsyncMock(return_value=None)):
- r = client.post("/api/message/stream",
- json={"content": "hi", "session_id": "s-1"})
- self.assertEqual(r.status_code, 200)
- records = self._read_ndjson(r.content)
- # At least one chunk and exactly one final.
- types = [rec["type"] for rec in records]
- self.assertIn("chunk", types)
- self.assertEqual(types.count("final"), 1)
- # Final carries session_id + complete content.
- final = records[-1]
- self.assertEqual(final["session_id"], "s-1")
- self.assertIn("Hello", final["content"])
-
- def test_stream_timeout_emits_error_frame(self):
- bridge_app.acp.prompt = AsyncMock(side_effect=__import__("asyncio").TimeoutError())
- with patch.object(bridge_app, "_refresh_caches",
- new=AsyncMock(return_value=None)):
- r = client.post("/api/message/stream",
- json={"content": "hi"})
- records = self._read_ndjson(r.content)
- types = [rec["type"] for rec in records]
- self.assertIn("error", types)
- err = next(r for r in records if r["type"] == "error")
- self.assertIn("thinking too slowly", err["message"])
-
-
-# ---------------------------------------------------------------------------
-# /api/perception/feed — SSE smoke
-# ---------------------------------------------------------------------------
-
-class PerceptionFeedTests(unittest.IsolatedAsyncioTestCase):
- """Functional test of the perception bus the SSE route consumes —
- subscribe(), broadcast(), unsubscribe().
-
- A full SSE round-trip through TestClient or httpx.AsyncClient hangs:
- SSE responses don't flush headers until the first body chunk, and
- the route's keepalive sits in queue.get() for 15s before its first
- yield. Driving the whole loop requires either a real HTTP server or
- a frame-level transport mock. Both are out of scope for boundary
- smoke tests, so we cover the same logic by exercising the route's
- actual dependencies directly. Tracking proper SSE coverage as a
- follow-up."""
-
- async def asyncSetUp(self):
- _install_acp_stub()
- _reset_perception_state()
- # Clean slate — leftover queues from earlier tests would pull events.
- bridge_app._perception_listeners.clear()
-
- async def test_subscribe_receives_broadcast(self):
- import asyncio as _asyncio
-
- queue = bridge_app._perception_subscribe()
- try:
- bridge_app._perception_broadcast({
- "name": "face_detected",
- "device_id": "dev-1",
- "ts": 1234.5,
- "data": {"hint": "test"},
- })
- event = await _asyncio.wait_for(queue.get(), timeout=1.0)
- self.assertEqual(event["name"], "face_detected")
- self.assertEqual(event["device_id"], "dev-1")
- self.assertEqual(event["data"], {"hint": "test"})
- finally:
- bridge_app._perception_unsubscribe(queue)
-
- async def test_unsubscribe_drops_listener(self):
- queue = bridge_app._perception_subscribe()
- self.assertIn(queue, bridge_app._perception_listeners)
- bridge_app._perception_unsubscribe(queue)
- self.assertNotIn(queue, bridge_app._perception_listeners)
-
- async def test_broadcast_with_no_listeners_is_noop(self):
- # Empty listeners list — broadcast must not raise.
- self.assertEqual(bridge_app._perception_listeners, [])
- bridge_app._perception_broadcast({
- "name": "face_detected", "device_id": "x", "ts": 0.0, "data": {},
- })
-
-
-# ---------------------------------------------------------------------------
-# /api/vision/latest/{device_id} — event-driven waiter
-# ---------------------------------------------------------------------------
-
-class VisionLatestTests(unittest.IsolatedAsyncioTestCase):
- """The endpoint pops _vision_cache[device_id], registers an asyncio.Event
- waiter, then blocks for up to 15s. To exercise the happy path we use
- httpx.AsyncClient so we can run a concurrent populate-and-fire task on
- the same event loop."""
-
- async def asyncSetUp(self):
- _install_acp_stub()
- bridge_app._vision_cache.clear()
- bridge_app._vision_events.clear()
-
- async def test_returns_cached_description_when_populated_mid_wait(self):
- import asyncio as _asyncio
-
- import httpx
- from httpx import ASGITransport
-
- async def _populate():
- # Wait long enough for the endpoint to register its waiter.
- await _asyncio.sleep(0.05)
- bridge_app._vision_cache["dev1"] = {
- "description": "saw a cat",
- "room_match_person_id": None,
- }
- for ev in bridge_app._vision_events.get("dev1", []):
- ev.set()
-
- transport = ASGITransport(app=bridge_app.app)
- async with httpx.AsyncClient(transport=transport,
- base_url="http://test") as ac:
- populate_task = _asyncio.create_task(_populate())
- r = await ac.get("/api/vision/latest/dev1")
- await populate_task
-
- self.assertEqual(r.status_code, 200)
- body = r.json()
- self.assertEqual(body["description"], "saw a cat")
- self.assertIsNone(body["room_match_person_id"])
+ def test_no_legacy_acp_fields(self):
+ """Regression guard: if someone re-adds ACP-shaped fields here,
+ the dashboard contract has drifted — investigate before relaxing
+ this test."""
+ body = client.get("/health").json()
+ for legacy_key in ("acp_running", "cached_session", "session_turns"):
+ self.assertNotIn(legacy_key, body)
if __name__ == "__main__":
diff --git a/tests/test_dream_dance_writers.py b/tests/test_dream_dance_writers.py
deleted file mode 100644
index 85c0101..0000000
--- a/tests/test_dream_dance_writers.py
+++ /dev/null
@@ -1,164 +0,0 @@
-"""Unit tests for the sleep dreamer + dance reflector NDJSON writers
-and the dream-text SUMMARY parser.
-
-Pure unit — does NOT exercise the perception consumer loops
-end-to-end (those are bench-verified). Covers the helpers:
- * `_split_dream_text` — parses SUMMARY: line out of LLM reply
- * Dream NDJSON record shape
- * Dance NDJSON record shape
- * Dream schedule fractions (1/(N+1), 2/(N+1), …, N/(N+1))
-"""
-from __future__ import annotations
-
-import json
-import sys
-import tempfile
-import unittest
-from datetime import datetime
-from pathlib import Path
-from zoneinfo import ZoneInfo
-
-sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
-
-
-# Mirror of `_split_dream_text` in bridge.py — kept local so the test
-# doesn't import the FastAPI app. Keep in sync.
-def split_dream_text(raw: str) -> tuple[str, str | None]:
- if not raw:
- return "", None
- text = raw.rstrip()
- lines = text.splitlines()
- for i in range(len(lines) - 1, -1, -1):
- line = lines[i].strip()
- if line.lower().startswith("summary:"):
- summary = line.split(":", 1)[1].strip()
- full_text = "\n".join(lines[:i]).rstrip()
- return full_text, (summary or None)
- return text, None
-
-
-def schedule_fractions(window_s: float, n: int) -> list[float]:
- """Mirror of the `_schedule` math in `_perception_sleep_dreamer`.
- Returns the list of delays in seconds."""
- if n <= 0:
- return []
- return [window_s * (i / (n + 1)) for i in range(1, n + 1)]
-
-
-class SplitDreamTextTests(unittest.TestCase):
-
- def test_summary_extracted_when_present(self):
- raw = (
- "I drift through a corridor of mirrors, each one showing a "
- "different version of the kitchen.\n"
- "\n"
- "The clocks all read different times.\n"
- "\n"
- "SUMMARY: A dream of mirrored kitchens with desynchronised clocks."
- )
- full, summary = split_dream_text(raw)
- self.assertNotIn("SUMMARY:", full)
- self.assertEqual(
- summary, "A dream of mirrored kitchens with desynchronised clocks.",
- )
-
- def test_no_summary_returns_none(self):
- raw = "A dream with no trailing summary line.\n\nIt just ends."
- full, summary = split_dream_text(raw)
- self.assertIn("just ends", full)
- self.assertIsNone(summary)
-
- def test_empty_raw_returns_empty(self):
- full, summary = split_dream_text("")
- self.assertEqual(full, "")
- self.assertIsNone(summary)
-
- def test_summary_is_case_insensitive(self):
- raw = "Body of the dream.\nsummary: lowercase summary."
- full, summary = split_dream_text(raw)
- self.assertEqual(summary, "lowercase summary.")
- self.assertEqual(full, "Body of the dream.")
-
-
-class DreamRecordShapeTests(unittest.TestCase):
-
- def _record(self, **kwargs) -> dict:
- return {
- "ts": datetime.now(ZoneInfo("UTC")).isoformat(),
- "type": "dream",
- "dream_id": kwargs.get("dream_id", "abc123"),
- "seed": kwargs.get("seed", "Murakami"),
- "summary": kwargs.get("summary", "test summary"),
- "full_text": kwargs.get("full_text", "the full dream text"),
- }
-
- def test_record_required_fields(self):
- with tempfile.TemporaryDirectory() as td:
- p = Path(td) / "dreams-test.ndjson"
- with open(p, "a", encoding="utf-8") as fh:
- fh.write(json.dumps(self._record()) + "\n")
- data = json.loads(p.read_text(encoding="utf-8").strip())
- for k in ("ts", "type", "dream_id", "seed", "summary", "full_text"):
- self.assertIn(k, data)
- self.assertEqual(data["type"], "dream")
-
- def test_summary_can_be_empty_string(self):
- # When LLM omits SUMMARY: we persist "" rather than null —
- # callers can filter on truthy vs not.
- rec = self._record(summary="")
- self.assertEqual(rec["summary"], "")
-
-
-class DanceRecordShapeTests(unittest.TestCase):
-
- def test_dance_record_required_fields(self):
- rec = {
- "ts": datetime.now(ZoneInfo("UTC")).isoformat(),
- "type": "dance",
- "device": "aa:bb",
- "dance": "wiggle",
- "reflection": "That was joyful and silly.",
- }
- for k in ("ts", "type", "device", "dance", "reflection"):
- self.assertIn(k, rec)
- self.assertEqual(rec["type"], "dance")
-
-
-class ScheduleFractionsTests(unittest.TestCase):
-
- def test_three_dreams_at_25_50_75_percent(self):
- # 8h window, 3 dreams → 25/50/75% = 7200/14400/21600s
- delays = schedule_fractions(28800.0, 3)
- self.assertEqual(len(delays), 3)
- self.assertAlmostEqual(delays[0], 7200.0, places=2)
- self.assertAlmostEqual(delays[1], 14400.0, places=2)
- self.assertAlmostEqual(delays[2], 21600.0, places=2)
-
- def test_three_minute_bench_window(self):
- # 180s window for bench testing → fires at 45/90/135s.
- delays = schedule_fractions(180.0, 3)
- self.assertEqual(len(delays), 3)
- self.assertAlmostEqual(delays[0], 45.0, places=2)
- self.assertAlmostEqual(delays[1], 90.0, places=2)
- self.assertAlmostEqual(delays[2], 135.0, places=2)
-
- def test_zero_count_returns_empty(self):
- self.assertEqual(schedule_fractions(28800.0, 0), [])
-
- def test_single_dream_at_midpoint(self):
- # N=1 → 1/(1+1) = 50% of the window.
- delays = schedule_fractions(28800.0, 1)
- self.assertEqual(len(delays), 1)
- self.assertAlmostEqual(delays[0], 14400.0, places=2)
-
- def test_delays_strictly_within_window(self):
- # All delays must be > 0 and < window. With 3 dreams the
- # last one fires at 75% of the window — never on or after it.
- delays = schedule_fractions(28800.0, 3)
- for d in delays:
- self.assertGreater(d, 0)
- self.assertLess(d, 28800.0)
-
-
-if __name__ == "__main__":
- unittest.main()
diff --git a/tests/test_household.py b/tests/test_household.py
deleted file mode 100644
index f10934d..0000000
--- a/tests/test_household.py
+++ /dev/null
@@ -1,348 +0,0 @@
-"""Unit tests for `bridge.household.HouseholdRegistry`.
-
-Pure-unit tests — no network. Filesystem use is confined to `tmp_path`.
-"""
-from __future__ import annotations
-
-import sys
-import textwrap
-import unittest
-from datetime import date
-from pathlib import Path
-
-sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
-
-from bridge.household import ( # noqa: E402
- DEFAULT_PERSON_FALLBACK,
- HouseholdRegistry,
- Person,
-)
-
-
-def _write(path: Path, body: str) -> Path:
- path.write_text(textwrap.dedent(body), encoding="utf-8")
- return path
-
-
-class TestPerson(unittest.TestCase):
- def test_compact_description_includes_age_and_interests(self) -> None:
- p = Person(
- id="sam",
- display_name="Sam",
- age=7,
- personality="curious and chatty",
- interests=("lego", "dinosaurs", "soccer", "minecraft"),
- )
- out = p.compact_description()
- self.assertIn("Sam", out)
- self.assertIn("7yo", out)
- self.assertIn("curious", out)
- # Caps interests at 3 items.
- self.assertIn("lego", out)
- self.assertIn("dinosaurs", out)
- self.assertIn("soccer", out)
- self.assertNotIn("minecraft", out)
-
- def test_compact_description_truncates(self) -> None:
- p = Person(
- id="alex", display_name="Alex",
- personality="x" * 500,
- )
- out = p.compact_description(max_chars=50)
- self.assertLessEqual(len(out), 50)
- self.assertTrue(out.endswith("…"))
-
- def test_compact_description_no_pii(self) -> None:
- # Birthdate is structured but never appears in the compact
- # description — that's the privacy contract.
- p = Person(
- id="alex", display_name="Alex",
- birthdate=date(1985, 6, 12),
- )
- self.assertNotIn("1985", p.compact_description())
- self.assertNotIn("06", p.compact_description())
-
- def test_days_until_birthday_today(self) -> None:
- today = date(2026, 4, 25)
- p = Person(id="x", display_name="x", birthdate=date(2000, 4, 25))
- self.assertEqual(p.days_until_birthday(today=today), 0)
-
- def test_days_until_birthday_future(self) -> None:
- today = date(2026, 4, 25)
- p = Person(id="x", display_name="x", birthdate=date(2000, 5, 1))
- self.assertEqual(p.days_until_birthday(today=today), 6)
-
- def test_days_until_birthday_wraps_to_next_year(self) -> None:
- today = date(2026, 4, 25)
- p = Person(id="x", display_name="x", birthdate=date(2000, 1, 1))
- # Jan 1 already passed; next is Jan 1 2027.
- self.assertEqual(
- p.days_until_birthday(today=today),
- (date(2027, 1, 1) - today).days,
- )
-
- def test_days_until_birthday_leap_day(self) -> None:
- today = date(2026, 2, 27)
- p = Person(id="x", display_name="x", birthdate=date(2000, 2, 29))
- # 2026 is not a leap year — Feb 28 is the standin.
- self.assertEqual(p.days_until_birthday(today=today), 1)
-
- def test_days_until_birthday_none_without_birthdate(self) -> None:
- p = Person(id="x", display_name="x")
- self.assertIsNone(p.days_until_birthday())
-
-
-class TestRegistryLoading(unittest.TestCase):
- def setUp(self) -> None:
- self.tmp = Path(self.id())
- self._cleanup_paths: list[Path] = []
-
- def tearDown(self) -> None:
- for p in self._cleanup_paths:
- try:
- p.unlink()
- except OSError:
- pass
-
- def _yaml_path(self, body: str) -> Path:
- import tempfile
- fd, raw = tempfile.mkstemp(suffix=".yaml")
- import os
- os.close(fd)
- path = Path(raw)
- self._cleanup_paths.append(path)
- return _write(path, body)
-
- def test_missing_file_yields_empty_registry(self) -> None:
- reg = HouseholdRegistry(path="/nonexistent/path/household.yaml")
- self.assertEqual(tuple(reg.iter()), ())
- self.assertEqual(reg.default_person, DEFAULT_PERSON_FALLBACK)
- self.assertIsNone(reg.get("anyone"))
-
- def test_basic_load(self) -> None:
- path = self._yaml_path("""
- default_person: _household
- people:
- sam:
- display_name: Sam
- age: 7
- interests: [lego, dinosaurs]
- self_id_phrases: ["it's sam", "sam here"]
- calendar_prefix: "[Sam]"
- """)
- reg = HouseholdRegistry(path=path)
- sam = reg.get("sam")
- self.assertIsNotNone(sam)
- assert sam is not None
- self.assertEqual(sam.display_name, "Sam")
- self.assertEqual(sam.age, 7)
- self.assertEqual(sam.interests, ("lego", "dinosaurs"))
- self.assertEqual(sam.calendar_prefix, "[Sam]")
-
- def test_lookup_case_insensitive(self) -> None:
- path = self._yaml_path("""
- people:
- sam:
- display_name: Sam
- """)
- reg = HouseholdRegistry(path=path)
- self.assertIsNotNone(reg.get("SAM"))
- self.assertIsNotNone(reg.get("Sam"))
-
- def test_calendar_prefix_lookup(self) -> None:
- path = self._yaml_path("""
- people:
- sam:
- display_name: Sam
- calendar_prefix: "[Sam]"
- """)
- reg = HouseholdRegistry(path=path)
- self.assertIsNotNone(reg.get_by_calendar_prefix("[Sam]"))
- self.assertIsNotNone(reg.get_by_calendar_prefix("[sam]"))
- self.assertIsNotNone(
- reg.get_by_calendar_prefix("Sam"),
- "brackets should be optional in lookup",
- )
- self.assertIsNone(reg.get_by_calendar_prefix("[Riley]"))
-
- def test_birthdate_iso_string_parses(self) -> None:
- path = self._yaml_path("""
- people:
- sam:
- display_name: Sam
- birthdate: "2018-11-03"
- """)
- reg = HouseholdRegistry(path=path)
- sam = reg.get("sam")
- assert sam is not None
- self.assertEqual(sam.birthdate, date(2018, 11, 3))
-
- def test_birthdate_yaml_native_date_works(self) -> None:
- # PyYAML auto-parses YYYY-MM-DD scalars to datetime.date.
- path = self._yaml_path("""
- people:
- sam:
- display_name: Sam
- birthdate: 2018-11-03
- """)
- reg = HouseholdRegistry(path=path)
- sam = reg.get("sam")
- assert sam is not None
- self.assertEqual(sam.birthdate, date(2018, 11, 3))
-
- def test_birthdate_unparseable_kept_none(self) -> None:
- path = self._yaml_path("""
- people:
- sam:
- display_name: Sam
- birthdate: "not-a-date"
- """)
- reg = HouseholdRegistry(path=path)
- sam = reg.get("sam")
- assert sam is not None
- self.assertIsNone(sam.birthdate)
-
- def test_malformed_yaml_yields_empty_registry(self) -> None:
- path = self._yaml_path("this is :: not valid yaml ::: -:- ::\n - x")
- # PyYAML may or may not raise on this depending on version; either
- # way the registry must not crash and should resolve as empty or
- # near-empty without any spurious people.
- reg = HouseholdRegistry(path=path)
- self.assertNotIn("malformed", str(reg.iter()))
-
- def test_skips_reserved_default_id(self) -> None:
- path = self._yaml_path("""
- people:
- _household:
- display_name: Should be skipped
- sam:
- display_name: Sam
- """)
- reg = HouseholdRegistry(path=path)
- self.assertIsNone(reg.get("_household"))
- self.assertIsNotNone(reg.get("sam"))
-
- def test_default_person_override(self) -> None:
- path = self._yaml_path("""
- default_person: family
- people:
- sam: {display_name: Sam}
- """)
- reg = HouseholdRegistry(path=path)
- self.assertEqual(reg.default_person, "family")
-
-
-class TestSelfIdMatching(unittest.TestCase):
- def _registry(self) -> HouseholdRegistry:
- import tempfile
- fd, raw = tempfile.mkstemp(suffix=".yaml")
- import os
- os.close(fd)
- path = Path(raw)
- path.write_text(textwrap.dedent("""
- people:
- alex:
- display_name: Alex
- self_id_phrases:
- - "it's alex"
- - "i'm alex"
- - "alex here"
- sam:
- display_name: Sam
- self_id_phrases:
- - "it's sam"
- - "sam here"
- brettany:
- display_name: Brettany
- self_id_phrases:
- - "it's brettany"
- """).strip(), encoding="utf-8")
- self.addCleanup(lambda: path.unlink(missing_ok=True))
- return HouseholdRegistry(path=path)
-
- def test_basic_match(self) -> None:
- reg = self._registry()
- p = reg.match_self_id("It's Alex.")
- self.assertIsNotNone(p)
- assert p is not None
- self.assertEqual(p.id, "alex")
-
- def test_match_case_insensitive(self) -> None:
- reg = self._registry()
- self.assertIsNotNone(reg.match_self_id("IT'S ALEX"))
- self.assertIsNotNone(reg.match_self_id("it's alex"))
-
- def test_match_strips_leading_punctuation(self) -> None:
- reg = self._registry()
- self.assertIsNotNone(reg.match_self_id(" — It's Alex"))
- self.assertIsNotNone(reg.match_self_id("...sam here"))
-
- def test_no_match_when_phrase_not_at_start(self) -> None:
- reg = self._registry()
- self.assertIsNone(
- reg.match_self_id("I told alex earlier"),
- "phrase must be leading-position to count as self-ID",
- )
-
- def test_word_boundary_protects_against_substring_collision(self) -> None:
- reg = self._registry()
- # "it's brett" is NOT one of Brettany's phrases — only "it's
- # brettany" — so this case is purely about the "alex" /
- # "alexander" risk. We register "it's alex" and ensure
- # "it's alexander" does NOT match alex.
- # (Brettany is here purely so the longest-first sort gets exercised.)
- # Match against alex's phrase "it's alex":
- self.assertIsNone(reg.match_self_id("it's alexander"))
-
- def test_longest_phrase_wins(self) -> None:
- reg = self._registry()
- # Both "it's brettany" and "it's b..." would match "it's brettany"
- # exactly; the test is that the longer phrase is checked first.
- p = reg.match_self_id("it's brettany!")
- assert p is not None
- self.assertEqual(p.id, "brettany")
-
- def test_empty_text_returns_none(self) -> None:
- reg = self._registry()
- self.assertIsNone(reg.match_self_id(""))
- self.assertIsNone(reg.match_self_id(" "))
-
-
-class TestHotReload(unittest.TestCase):
- def test_reload_picks_up_changes(self) -> None:
- import os
- import tempfile
- fd, raw = tempfile.mkstemp(suffix=".yaml")
- os.close(fd)
- path = Path(raw)
- self.addCleanup(lambda: path.unlink(missing_ok=True))
-
- path.write_text(textwrap.dedent("""
- people:
- sam: {display_name: Sam}
- """), encoding="utf-8")
- reg = HouseholdRegistry(path=path)
- self.assertIsNotNone(reg.get("sam"))
- self.assertIsNone(reg.get("riley"))
-
- # Move mtime forward by writing new contents. We bump the mtime
- # explicitly because some filesystems (and some test runners)
- # collapse two writes within the same second to the same mtime.
- import time
- future = time.time() + 5
- path.write_text(textwrap.dedent("""
- people:
- riley: {display_name: Riley}
- """), encoding="utf-8")
- os.utime(path, (future, future))
-
- # First access after change triggers reload.
- self.assertIsNotNone(reg.get("riley"))
- self.assertIsNone(
- reg.get("sam"),
- "old entry should be gone after reload picks up rewritten file",
- )
-
-
-if __name__ == "__main__": # pragma: no cover
- unittest.main()
diff --git a/tests/test_idle_photographer.py b/tests/test_idle_photographer.py
deleted file mode 100644
index 1c55456..0000000
--- a/tests/test_idle_photographer.py
+++ /dev/null
@@ -1,135 +0,0 @@
-"""Unit tests for the idle photographer's notability filter and
-NDJSON record shape.
-
-The async loop itself is not exercised end-to-end here — that lives
-in bench verification. This module covers the pure helpers:
-
- * `_is_notable_perception` — Jaccard threshold + edge cases
- * idle-perception NDJSON record schema (one line, expected fields)
-"""
-from __future__ import annotations
-
-import json
-import sys
-import tempfile
-import unittest
-from datetime import datetime
-from pathlib import Path
-from zoneinfo import ZoneInfo
-
-# Repo root importable so `bridge.perception` resolves.
-sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
-
-
-# Mirror the bridge's notability helper without importing bridge.py
-# (which constructs FastAPI on import). Keep this in sync with
-# `_is_notable_perception` in bridge.py — the test verifies the
-# threshold, not the implementation site.
-import re
-
-_TOKEN_RE = re.compile(r"\w+")
-
-
-def is_notable(desc: str, last: str | None, *, jaccard: float = 0.7) -> bool:
- if not desc or len(desc) < 20:
- return False
- if "same as before" in desc.lower():
- return False
- if not last:
- return True
- cur = set(t.lower() for t in _TOKEN_RE.findall(desc))
- prev = set(t.lower() for t in _TOKEN_RE.findall(last))
- if not cur or not prev:
- return True
- union = cur | prev
- if not union:
- return True
- return (len(cur & prev) / len(union)) < jaccard
-
-
-class NotabilityTests(unittest.TestCase):
-
- def test_too_short_is_not_notable(self):
- self.assertFalse(is_notable("hi", None))
-
- def test_empty_is_not_notable(self):
- self.assertFalse(is_notable("", None))
-
- def test_first_observation_is_notable(self):
- self.assertTrue(is_notable(
- "A quiet desk lit by a warm lamp, books stacked nearby.", None,
- ))
-
- def test_same_as_before_is_skipped(self):
- self.assertFalse(is_notable(
- "Same as before — nothing has changed in the room.",
- "A quiet desk lit by a warm lamp.",
- ))
-
- def test_substantially_different_is_notable(self):
- prev = "A quiet desk lit by a warm lamp, books stacked nearby."
- cur = "A child playing with red and blue lego bricks on the floor."
- self.assertTrue(is_notable(cur, prev))
-
- def test_near_duplicate_is_skipped(self):
- prev = "A quiet desk lit by a warm lamp, with books stacked nearby."
- cur = "A quiet desk lit by a warm lamp, with books stacked together."
- # Nearly all tokens overlap → Jaccard > 0.7 → skip.
- self.assertFalse(is_notable(cur, prev))
-
- def test_threshold_is_tunable(self):
- # Same scene as near-duplicate test, but with a stricter
- # threshold the "same" scene becomes notable.
- prev = "A quiet desk lit by a warm lamp, with books stacked nearby."
- cur = "A quiet desk lit by a warm lamp, with books stacked together."
- self.assertTrue(is_notable(cur, prev, jaccard=0.99))
-
-
-class NdjsonRecordShapeTests(unittest.TestCase):
- """Verify the on-disk record schema — one JSON line, expected
- fields, no media, mode 0600. Mirrors the bridge's
- `_write_idle_perception_record`.
- """
-
- def _write_record(self, path: Path, device_id: str, description: str) -> None:
- # Synth — keeps the test independent of bridge.py FastAPI ctor.
- record = {
- "ts": datetime.now(ZoneInfo("UTC")).isoformat(),
- "device": device_id,
- "type": "perception",
- "mode": "idle",
- "text": description,
- }
- path.parent.mkdir(parents=True, exist_ok=True)
- with open(path, "a", encoding="utf-8") as fh:
- fh.write(json.dumps(record, ensure_ascii=False) + "\n")
-
- def test_record_is_single_line(self):
- with tempfile.TemporaryDirectory() as td:
- p = Path(td) / "perception-test.ndjson"
- self._write_record(p, "aa:bb", "A warm lamp on the desk.")
- content = p.read_text(encoding="utf-8")
- self.assertEqual(content.count("\n"), 1)
-
- def test_record_has_required_fields(self):
- with tempfile.TemporaryDirectory() as td:
- p = Path(td) / "perception-test.ndjson"
- self._write_record(p, "aa:bb", "A warm lamp on the desk.")
- line = p.read_text(encoding="utf-8").strip()
- data = json.loads(line)
- for k in ("ts", "device", "type", "mode", "text"):
- self.assertIn(k, data, f"missing field: {k}")
- self.assertEqual(data["type"], "perception")
- self.assertEqual(data["mode"], "idle")
-
- def test_record_has_no_jpeg_or_audio(self):
- with tempfile.TemporaryDirectory() as td:
- p = Path(td) / "perception-test.ndjson"
- self._write_record(p, "aa:bb", "A warm lamp on the desk.")
- data = json.loads(p.read_text(encoding="utf-8").strip())
- for forbidden in ("jpeg_bytes", "audio_bytes", "image", "wav"):
- self.assertNotIn(forbidden, data)
-
-
-if __name__ == "__main__":
- unittest.main()
diff --git a/tests/test_memory_type_tags.py b/tests/test_memory_type_tags.py
deleted file mode 100644
index 26e98c3..0000000
--- a/tests/test_memory_type_tags.py
+++ /dev/null
@@ -1,71 +0,0 @@
-"""Unit test for the cross-cutting `type` discriminator added in commit 6.
-
-Verifies the convention: every NDJSON memory write the bridge produces
-carries a `type` field. Future ZeroClaw FTS ingestion routines can
-filter on this field (`type:dream`, `type:perception`, etc.) without
-guessing from the filename.
-
-The taxonomy this commit standardises:
-
- type=chat — _ConvoLogger.log_turn → convo-YYYY-MM-DD.ndjson
- type=perception — idle photographer → perception-YYYY-MM-DD.ndjson
- type=dream — sleep dreamer → dreams-YYYY-MM-DD.ndjson
- type=dance — dance reflector → dances-YYYY-MM-DD.ndjson
- type=scene_synthesis — scene synthesis loop → scene-synthesis-YYYY-MM-DD.ndjson
-
-This test asserts each documented value remains a string in the
-expected shape — adding a new value without updating the taxonomy
-should fail this test.
-"""
-from __future__ import annotations
-
-import unittest
-
-
-KNOWN_TYPES = frozenset({
- "chat",
- "perception",
- "dream",
- "dance",
- "scene_synthesis",
-})
-
-
-class TypeTagTaxonomyTests(unittest.TestCase):
-
- def test_known_types_are_strings(self):
- # Catches accidental tuple/None/int sneaking into the constant.
- for t in KNOWN_TYPES:
- self.assertIsInstance(t, str)
- self.assertTrue(t.islower())
- self.assertGreater(len(t), 0)
-
- def test_no_whitespace_in_tags(self):
- # FTS queries split on whitespace — a tag with a space breaks
- # `tag:perception` / `tag:scene_synthesis` lookups.
- for t in KNOWN_TYPES:
- self.assertNotIn(" ", t)
- self.assertNotIn("\t", t)
- self.assertNotIn("-", t, f"use underscore not hyphen: {t!r}")
-
- def test_chat_is_default_for_convo(self):
- # _ConvoLogger.log_turn defaults to type="chat" — keep that
- # contract stable so untyped legacy log_turn() callers still
- # land in the right bucket.
- self.assertIn("chat", KNOWN_TYPES)
-
- def test_taxonomy_documented_in_tools_md(self):
- # Smoke check: this taxonomy must be in sync with the live
- # /root/.zeroclaw/workspace/TOOLS.md on the RPi (deployed by
- # the commit-6 deploy step). The test doesn't read the Pi
- # (no network in unit tests), but locks the assertion into
- # the test so a future change here triggers a docs update.
- # If you change KNOWN_TYPES, also update TOOLS.md.
- # The check is purely a reminder: if this list grows or
- # shrinks, fail to remind the developer to push the doc.
- expected = {"chat", "perception", "dream", "dance", "scene_synthesis"}
- self.assertEqual(KNOWN_TYPES, expected)
-
-
-if __name__ == "__main__":
- unittest.main()
diff --git a/tests/test_perception_cache.py b/tests/test_perception_cache.py
deleted file mode 100644
index 3d89029..0000000
--- a/tests/test_perception_cache.py
+++ /dev/null
@@ -1,365 +0,0 @@
-"""Unit tests for bridge.perception.cache — the read-only snapshot
-façade over the four bridge per-device caches.
-
-Pure unit. Doesn't import bridge.py; constructs synthetic cache dicts
-in the same shape bridge.py uses (`wall_ts` for vision/audio,
-`ts_wall` for scene synthesis — both spellings exist in the codebase).
-"""
-from __future__ import annotations
-
-import sys
-import time
-import unittest
-from pathlib import Path
-
-# Make the repo root importable so `bridge.perception` resolves whether
-# tests are run from the repo root or another working directory.
-sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
-
-from bridge.perception import PerceptionSnapshot, snapshot
-from bridge.perception.cache import (
- AUDIO_AGE_GATE_SEC,
- FACE_IDENTITY_AGE_GATE_SEC,
- SCENE_SYNTH_AGE_GATE_SEC,
- VISION_AGE_GATE_SEC,
-)
-
-
-DEVICE = "aa:bb:cc:dd:ee:ff"
-
-
-def _empty_caches():
- return dict(
- perception_state={},
- vision_cache={},
- audio_cache={},
- scene_synthesis_cache={},
- )
-
-
-class SnapshotEmptyTests(unittest.TestCase):
-
- def test_empty_caches_returns_off_state(self):
- snap = snapshot(DEVICE, **_empty_caches())
- self.assertEqual(snap.face, "off")
- self.assertIsNone(snap.face_id)
- self.assertFalse(snap.listening)
- self.assertEqual(snap.state, "idle")
- self.assertIsNone(snap.last_vision_desc)
- self.assertIsNone(snap.last_audio_desc)
- self.assertIsNone(snap.scene_synth)
-
- def test_empty_snapshot_yields_empty_prompt_block(self):
- snap = snapshot(DEVICE, **_empty_caches())
- self.assertEqual(snap.to_prompt_block(), "")
-
- def test_none_device_id_safe(self):
- snap = snapshot(None, **_empty_caches())
- self.assertEqual(snap.face, "off")
- self.assertEqual(snap.to_prompt_block(), "")
-
-
-class FaceStateTests(unittest.TestCase):
-
- def test_face_present_unknown_id_is_detected(self):
- caches = _empty_caches()
- caches["perception_state"][DEVICE] = {
- "face_present": True,
- "last_face_id": "unknown",
- }
- snap = snapshot(DEVICE, **caches)
- self.assertEqual(snap.face, "detected")
- self.assertIsNone(snap.face_id)
-
- def test_face_present_with_fresh_identity_is_identified(self):
- now = time.time()
- caches = _empty_caches()
- caches["perception_state"][DEVICE] = {
- "face_present": True,
- "last_face_id": "hudson",
- "last_face_recognized_t": now - 1.0,
- }
- snap = snapshot(DEVICE, **caches)
- self.assertEqual(snap.face, "identified")
- self.assertEqual(snap.face_id, "hudson")
-
- def test_face_absent_with_fresh_identity_still_identified(self):
- # TTL semantics: detector flicker briefly clears face_present, but
- # we keep the identification visible for FACE_IDENTITY_AGE_GATE_SEC
- # so the dashboard chip + perception snapshot don't collapse to
- # "off"/"detected" between flicker frames.
- now = time.time()
- caches = _empty_caches()
- caches["perception_state"][DEVICE] = {
- "face_present": False,
- "last_face_id": "hudson",
- "last_face_recognized_t": now - 1.0,
- }
- snap = snapshot(DEVICE, **caches)
- self.assertEqual(snap.face, "identified")
- self.assertEqual(snap.face_id, "hudson")
-
- def test_face_absent_with_stale_identity_is_off(self):
- # Past the TTL we drop back to "off" — person actually left.
- now = time.time()
- caches = _empty_caches()
- caches["perception_state"][DEVICE] = {
- "face_present": False,
- "last_face_id": "hudson",
- "last_face_recognized_t": now - (FACE_IDENTITY_AGE_GATE_SEC + 5),
- }
- snap = snapshot(DEVICE, **caches)
- self.assertEqual(snap.face, "off")
- self.assertIsNone(snap.face_id)
-
- def test_identity_without_recognized_timestamp_is_not_identified(self):
- # `last_face_recognized_t` is the load-bearing freshness signal —
- # a stray `last_face_id` without a timestamp can't promote face to
- # identified (would have done so under the pre-TTL behaviour).
- caches = _empty_caches()
- caches["perception_state"][DEVICE] = {
- "face_present": True,
- "last_face_id": "hudson",
- }
- snap = snapshot(DEVICE, **caches)
- self.assertEqual(snap.face, "detected")
- self.assertIsNone(snap.face_id)
-
-
-class VisionAudioTTLTests(unittest.TestCase):
-
- def test_fresh_vision_appears(self):
- now = time.time()
- caches = _empty_caches()
- caches["vision_cache"][DEVICE] = {
- "description": "a desk lit by warm lamp",
- "wall_ts": now - 5.0,
- }
- snap = snapshot(DEVICE, **caches)
- self.assertEqual(snap.last_vision_desc, "a desk lit by warm lamp")
-
- def test_stale_vision_excluded(self):
- now = time.time()
- caches = _empty_caches()
- caches["vision_cache"][DEVICE] = {
- "description": "stale stuff",
- "wall_ts": now - (VISION_AGE_GATE_SEC + 5.0),
- }
- snap = snapshot(DEVICE, **caches)
- self.assertIsNone(snap.last_vision_desc)
-
- def test_audio_uses_120s_ttl(self):
- now = time.time()
- caches = _empty_caches()
- caches["audio_cache"][DEVICE] = {
- "description": "soft footsteps",
- "wall_ts": now - (AUDIO_AGE_GATE_SEC - 5.0),
- }
- snap = snapshot(DEVICE, **caches)
- self.assertEqual(snap.last_audio_desc, "soft footsteps")
-
- def test_scene_synthesis_uses_ts_wall_key(self):
- now = time.time()
- caches = _empty_caches()
- # Note: scene synthesis writer uses `ts_wall`, not `wall_ts`.
- caches["scene_synthesis_cache"][DEVICE] = {
- "text": "Hudson is at the kitchen table reading.",
- "ts_wall": now - 30.0,
- }
- snap = snapshot(DEVICE, **caches)
- self.assertEqual(
- snap.scene_synth, "Hudson is at the kitchen table reading."
- )
-
- def test_stale_scene_synthesis_excluded(self):
- now = time.time()
- caches = _empty_caches()
- caches["scene_synthesis_cache"][DEVICE] = {
- "text": "old synth",
- "ts_wall": now - (SCENE_SYNTH_AGE_GATE_SEC + 60.0),
- }
- snap = snapshot(DEVICE, **caches)
- self.assertIsNone(snap.scene_synth)
-
-
-class PromptBlockTests(unittest.TestCase):
-
- def test_block_starts_with_marker_and_ends_with_newline(self):
- now = time.time()
- caches = _empty_caches()
- caches["perception_state"][DEVICE] = {
- "face_present": True,
- "last_face_id": "hudson",
- "last_face_recognized_t": now - 1.0,
- }
- snap = snapshot(DEVICE, **caches)
- block = snap.to_prompt_block()
- self.assertTrue(block.startswith("[Current perception] "))
- self.assertTrue(block.endswith("\n"))
-
- def test_identified_face_renders_name(self):
- now = time.time()
- caches = _empty_caches()
- caches["perception_state"][DEVICE] = {
- "face_present": True,
- "last_face_id": "hudson",
- "last_face_recognized_t": now - 1.0,
- }
- snap = snapshot(DEVICE, **caches)
- self.assertIn("hudson", snap.to_prompt_block())
-
- def test_detected_unknown_face_renders_unrecognised_phrase(self):
- caches = _empty_caches()
- caches["perception_state"][DEVICE] = {
- "face_present": True, "last_face_id": "",
- }
- snap = snapshot(DEVICE, **caches)
- self.assertIn("unrecognised", snap.to_prompt_block())
-
- def test_synth_prefers_over_raw_vision_audio(self):
- now = time.time()
- caches = _empty_caches()
- caches["scene_synthesis_cache"][DEVICE] = {
- "text": "The room is calm.", "ts_wall": now,
- }
- caches["vision_cache"][DEVICE] = {
- "description": "raw vision desc", "wall_ts": now,
- }
- caches["audio_cache"][DEVICE] = {
- "description": "raw audio desc", "wall_ts": now,
- }
- block = snapshot(DEVICE, **caches).to_prompt_block()
- self.assertIn("The room is calm.", block)
- self.assertNotIn("raw vision desc", block)
- self.assertNotIn("raw audio desc", block)
-
- def test_raw_vision_audio_used_when_no_synth(self):
- now = time.time()
- caches = _empty_caches()
- caches["vision_cache"][DEVICE] = {
- "description": "warm desk lamp", "wall_ts": now,
- }
- caches["audio_cache"][DEVICE] = {
- "description": "soft music", "wall_ts": now,
- }
- block = snapshot(DEVICE, **caches).to_prompt_block()
- self.assertIn("warm desk lamp", block)
- self.assertIn("soft music", block)
-
- def test_block_is_single_line_with_trailing_newline(self):
- now = time.time()
- caches = _empty_caches()
- caches["perception_state"][DEVICE] = {
- "face_present": True,
- "last_face_id": "hudson",
- "last_face_recognized_t": now - 1.0,
- }
- caches["scene_synthesis_cache"][DEVICE] = {
- "text": "Cup of tea on the table.", "ts_wall": now,
- }
- block = snapshot(DEVICE, **caches).to_prompt_block()
- # One trailing newline; no embedded newlines (keeps prompt
- # ordering tight regardless of which other blocks are present).
- self.assertEqual(block.count("\n"), 1)
-
-
-class SnapshotIsFrozen(unittest.TestCase):
-
- def test_snapshot_is_frozen_dataclass(self):
- snap = snapshot(DEVICE, **_empty_caches())
- self.assertIsInstance(snap, PerceptionSnapshot)
- with self.assertRaises(Exception):
- snap.face = "identified" # type: ignore[misc]
-
-
-class FaceMoodTests(unittest.TestCase):
-
- def test_mood_plumbed_through_when_face_identified_and_fresh(self):
- now = time.time()
- caches = _empty_caches()
- caches["perception_state"][DEVICE] = {
- "face_present": True,
- "last_face_id": "hudson",
- "last_face_recognized_t": now - 1.0,
- "face_mood": "engaged",
- "face_mood_t": now - 1.0,
- }
- snap = snapshot(DEVICE, **caches)
- self.assertEqual(snap.face_mood, "engaged")
-
- def test_mood_cleared_when_face_absent_and_stale(self):
- # No identification timestamp + no mood timestamp = stale on both
- # axes. Snapshot scrubs the mood so it can't ride into the prompt.
- caches = _empty_caches()
- caches["perception_state"][DEVICE] = {
- "face_present": False,
- "face_mood": "engaged", # stale (no face_mood_t)
- }
- snap = snapshot(DEVICE, **caches)
- self.assertIsNone(snap.face_mood)
-
- def test_mood_dropped_when_identification_stale(self):
- # Mood lives or dies with the identification — once identification
- # ages past FACE_IDENTITY_AGE_GATE_SEC, mood goes too even if its
- # own timestamp is still within window.
- now = time.time()
- caches = _empty_caches()
- caches["perception_state"][DEVICE] = {
- "face_present": False,
- "last_face_id": "hudson",
- "last_face_recognized_t": now - (FACE_IDENTITY_AGE_GATE_SEC + 5),
- "face_mood": "engaged",
- "face_mood_t": now - 1.0,
- }
- snap = snapshot(DEVICE, **caches)
- self.assertIsNone(snap.face_mood)
-
- def test_mood_renders_in_prompt_block(self):
- now = time.time()
- caches = _empty_caches()
- caches["perception_state"][DEVICE] = {
- "face_present": True,
- "last_face_id": "hudson",
- "last_face_recognized_t": now - 1.0,
- "face_mood": "tired",
- "face_mood_t": now - 1.0,
- }
- block = snapshot(DEVICE, **caches).to_prompt_block()
- self.assertIn("hudson", block)
- self.assertIn("tired", block)
-
-
-class StoryFramingTests(unittest.TestCase):
-
- def test_story_framing_appears_in_story_time(self):
- caches = _empty_caches()
- caches["perception_state"][DEVICE] = {
- "current_state": "story_time",
- }
- block = snapshot(DEVICE, **caches).to_prompt_block()
- self.assertIn("inside the story", block)
- self.assertTrue(block.startswith("[Current perception]"))
-
- def test_no_story_framing_in_idle(self):
- caches = _empty_caches()
- caches["perception_state"][DEVICE] = {
- "current_state": "idle",
- }
- # Empty perception in idle still returns "" — story framing is
- # the only thing that promotes a no-signal snapshot to a
- # non-empty block.
- self.assertEqual(snapshot(DEVICE, **caches).to_prompt_block(), "")
-
- def test_no_story_framing_in_talk(self):
- caches = _empty_caches()
- caches["perception_state"][DEVICE] = {
- "current_state": "talk",
- "face_present": True,
- "last_face_id": "hudson",
- }
- block = snapshot(DEVICE, **caches).to_prompt_block()
- self.assertNotIn("inside the story", block)
-
-
-if __name__ == "__main__":
- unittest.main()
diff --git a/tests/test_proactive_greeter.py b/tests/test_proactive_greeter.py
deleted file mode 100644
index 0e2ecfa..0000000
--- a/tests/test_proactive_greeter.py
+++ /dev/null
@@ -1,453 +0,0 @@
-"""Unit tests for `bridge.proactive_greeter.ProactiveGreeter`.
-
-Pure-unit tests — no network, no filesystem outside `tmp_path`-style
-temp directories. Uses `unittest.mock.AsyncMock` for the LLM and TTS
-dependencies and a small fake perception bus.
-"""
-from __future__ import annotations
-
-import asyncio
-import json
-import os
-import tempfile
-import unittest
-from datetime import datetime
-from pathlib import Path
-from unittest.mock import AsyncMock
-from zoneinfo import ZoneInfo
-
-# Ensure the repo root is importable when running this file directly.
-import sys
-sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
-
-from bridge.proactive_greeter import ( # noqa: E402
- ProactiveGreeter,
-)
-
-
-class _FakeBus:
- """Minimal stand-in for the perception bus."""
-
- def __init__(self) -> None:
- self.queue: asyncio.Queue = asyncio.Queue()
- self.subscribed = 0
- self.unsubscribed = 0
-
- def subscribe(self) -> asyncio.Queue:
- self.subscribed += 1
- return self.queue
-
- def unsubscribe(self, q: asyncio.Queue) -> None:
- self.unsubscribed += 1
-
-
-class _FakeCalendar:
- """Calendar cache stub — empty events, never raises."""
-
- def __init__(self, events=None, summary=None) -> None:
- self._events = events or []
- self._summary = summary or []
-
- def get_events(self):
- return list(self._events)
-
- def summarize_for_prompt(self, events, *, person=None, include_household=True):
- return list(self._summary)
-
-
-def _greeter(
- *,
- bus=None,
- llm=None,
- calendar=None,
- tts=None,
- kid_mode=lambda: True,
- state_path: Path | None = None,
- fixed_now: datetime | None = None,
-):
- """Build a greeter with sensible defaults for tests.
-
- `fixed_now` (if provided) freezes both the wall-clock and TZ-aware
- "now" used for window checks via a clock callable + a custom tz.
- """
- bus = bus or _FakeBus()
- llm = llm or AsyncMock(return_value="Hi there!")
- calendar = calendar or _FakeCalendar()
- tts = tts or AsyncMock()
- if state_path is None:
- # Use a per-test temp file by default.
- td = tempfile.TemporaryDirectory()
- state_path = Path(td.name) / "greeter_state.json"
- # Keep td alive on the greeter to avoid GC mid-test.
- g = ProactiveGreeter(
- perception_bus=bus,
- llm_client=llm,
- calendar_cache=calendar,
- tts_pusher=tts,
- kid_mode_provider=kid_mode,
- clock=(lambda: fixed_now.timestamp()) if fixed_now else __import__("time").time,
- tz=fixed_now.tzinfo if fixed_now else ZoneInfo("Australia/Brisbane"),
- )
- # Override the state path to the temp file.
- g._state_path = state_path # type: ignore[attr-defined]
- g._state = {} # type: ignore[attr-defined]
- # _current_window and _today_key both derive from self._clock(), so
- # injecting clock=lambda: fixed_now.timestamp() above is sufficient.
- # No monkey-patching needed.
- return g, bus, llm, calendar, tts
-
-
-def _evt(name="face_recognized", identity="Hudson", device_id="dev-1", ts=1000.0):
- return {
- "name": name,
- "device_id": device_id,
- "ts": ts,
- "data": {"identity": identity},
- }
-
-
-class GreeterTests(unittest.IsolatedAsyncioTestCase):
- def setUp(self) -> None:
- # Ensure env doesn't bleed in from the host.
- for k in [
- "GREETER_ENABLED",
- "GREETER_USE_FACE_DETECTED",
- "GREETER_GREET_UNKNOWN",
- "GREETER_COOLDOWN_HOURS",
- "GREETER_PER_DAY_MAX",
- "GREETER_STATE_PATH",
- "GREETER_GREETING_MAX_WORDS",
- ]:
- os.environ.pop(k, None)
-
- async def test_greets_known_face_in_window(self):
- # Tuesday 07:30 in morning window.
- now = datetime(2026, 4, 21, 7, 30, tzinfo=ZoneInfo("Australia/Brisbane"))
- g, bus, llm, cal, tts = _greeter(fixed_now=now)
- await g._handle(_evt(identity="Hudson"))
- self.assertEqual(tts.await_count, 1)
- args, _ = tts.call_args
- self.assertEqual(args[0], "dev-1")
- self.assertIn("Hi there!", args[1])
-
- async def test_cooldown_blocks_repeat(self):
- now = datetime(2026, 4, 21, 7, 30, tzinfo=ZoneInfo("Australia/Brisbane"))
- g, bus, llm, cal, tts = _greeter(fixed_now=now)
- # Per-day cap defaults to 1, so two events must collapse to one.
- await g._handle(_evt(identity="Hudson", ts=now.timestamp()))
- await g._handle(_evt(identity="Hudson", ts=now.timestamp() + 5))
- self.assertEqual(tts.await_count, 1)
-
- async def test_cooldown_window_blocks_within_seconds(self):
- # Per-day cap raised so we isolate the cooldown check.
- os.environ["GREETER_PER_DAY_MAX"] = "10"
- os.environ["GREETER_COOLDOWN_HOURS"] = "1"
- now = datetime(2026, 4, 21, 7, 30, tzinfo=ZoneInfo("Australia/Brisbane"))
- g, bus, llm, cal, tts = _greeter(fixed_now=now)
- await g._handle(_evt(identity="Hudson", ts=now.timestamp()))
- # 30 min later — still inside 1 h cooldown.
- await g._handle(_evt(identity="Hudson", ts=now.timestamp() + 1800))
- self.assertEqual(tts.await_count, 1)
-
- async def test_greeting_fires_outside_legacy_windows(self):
- # 02:00 — used to be skipped under the morning/afternoon/evening
- # gate; now greets regardless of hour, subject only to cooldown
- # and per-day cap. Time-of-day label degrades to "night".
- now = datetime(2026, 4, 21, 2, 0, tzinfo=ZoneInfo("Australia/Brisbane"))
- g, bus, llm, cal, tts = _greeter(fixed_now=now)
- await g._handle(_evt(identity="Hudson"))
- self.assertEqual(tts.await_count, 1)
- llm.assert_awaited_once()
-
- async def test_unknown_face_default_skipped(self):
- now = datetime(2026, 4, 21, 7, 30, tzinfo=ZoneInfo("Australia/Brisbane"))
- g, bus, llm, cal, tts = _greeter(fixed_now=now)
- await g._handle(_evt(identity="unknown"))
- self.assertEqual(tts.await_count, 0)
-
- async def test_unknown_face_optin_greets(self):
- os.environ["GREETER_GREET_UNKNOWN"] = "true"
- now = datetime(2026, 4, 21, 7, 30, tzinfo=ZoneInfo("Australia/Brisbane"))
- g, bus, llm, cal, tts = _greeter(fixed_now=now)
- await g._handle(_evt(identity="unknown"))
- self.assertEqual(tts.await_count, 1)
- # No LLM call for unknown — we always use the canned line.
- llm.assert_not_awaited()
- _, text = tts.call_args.args
- self.assertIn("Hello", text)
-
- async def test_template_fallback_when_llm_raises(self):
- now = datetime(2026, 4, 21, 7, 30, tzinfo=ZoneInfo("Australia/Brisbane"))
- broken_llm = AsyncMock(side_effect=RuntimeError("openrouter down"))
- g, bus, llm, cal, tts = _greeter(fixed_now=now, llm=broken_llm)
- await g._handle(_evt(identity="Hudson"))
- self.assertEqual(tts.await_count, 1)
- _, text = tts.call_args.args
- self.assertEqual(text, "Good morning, Hudson!")
-
- async def test_face_detected_promotes_to_unknown_when_enabled(self):
- os.environ["GREETER_USE_FACE_DETECTED"] = "true"
- os.environ["GREETER_GREET_UNKNOWN"] = "true"
- now = datetime(2026, 4, 21, 7, 30, tzinfo=ZoneInfo("Australia/Brisbane"))
- g, bus, llm, cal, tts = _greeter(fixed_now=now)
- ev = {
- "name": "face_detected",
- "device_id": "dev-1",
- "ts": now.timestamp(),
- "data": {},
- }
- await g._handle(ev)
- self.assertEqual(tts.await_count, 1)
-
- async def test_face_detected_ignored_by_default(self):
- # use_face_detected default is False → face_detected events ignored.
- now = datetime(2026, 4, 21, 7, 30, tzinfo=ZoneInfo("Australia/Brisbane"))
- g, bus, llm, cal, tts = _greeter(fixed_now=now)
- ev = {
- "name": "face_detected",
- "device_id": "dev-1",
- "ts": now.timestamp(),
- "data": {},
- }
- await g._handle(ev)
- self.assertEqual(tts.await_count, 0)
-
- async def test_state_persists_round_trip(self):
- now = datetime(2026, 4, 21, 7, 30, tzinfo=ZoneInfo("Australia/Brisbane"))
- with tempfile.TemporaryDirectory() as td:
- state_path = Path(td) / "state.json"
- g, bus, llm, cal, tts = _greeter(
- fixed_now=now, state_path=state_path,
- )
- await g._handle(_evt(identity="Hudson", ts=now.timestamp()))
- self.assertTrue(state_path.exists())
- contents = json.loads(state_path.read_text())
- day = now.strftime("%Y-%m-%d")
- self.assertIn(day, contents)
- self.assertIn("Hudson", contents[day])
-
- async def test_corrupt_state_starts_fresh(self):
- now = datetime(2026, 4, 21, 7, 30, tzinfo=ZoneInfo("Australia/Brisbane"))
- with tempfile.TemporaryDirectory() as td:
- state_path = Path(td) / "state.json"
- state_path.write_text("{not valid json")
- g, bus, llm, cal, tts = _greeter(
- fixed_now=now, state_path=state_path,
- )
- # Reload should have been clean.
- g._state = g._load_state() # type: ignore[attr-defined]
- self.assertEqual(g._state, {}) # type: ignore[attr-defined]
- # And greeting should still fire.
- await g._handle(_evt(identity="Hudson", ts=now.timestamp()))
- self.assertEqual(tts.await_count, 1)
-
- async def test_tts_failure_swallowed(self):
- now = datetime(2026, 4, 21, 7, 30, tzinfo=ZoneInfo("Australia/Brisbane"))
- broken_tts = AsyncMock(side_effect=RuntimeError("network"))
- g, bus, llm, cal, tts = _greeter(fixed_now=now, tts=broken_tts)
- # Must NOT raise.
- await g._handle(_evt(identity="Hudson"))
- broken_tts.assert_awaited_once()
-
- async def test_bus_loop_dispatches_face_recognized(self):
- """End-to-end: event pushed onto the perception bus is picked up
- by the greeter's `_run` loop and reaches the TTS pusher. Closes
- the gap left by the per-handler-only test coverage."""
- now = datetime(2026, 4, 21, 7, 30, tzinfo=ZoneInfo("Australia/Brisbane"))
- g, bus, llm, cal, tts = _greeter(fixed_now=now)
- g.start()
- try:
- await bus.queue.put(_evt(identity="Hudson", ts=now.timestamp()))
- # Yield control until the greeter loop drains the queue.
- for _ in range(50):
- if tts.await_count >= 1:
- break
- await asyncio.sleep(0.01)
- self.assertEqual(tts.await_count, 1)
- args, _ = tts.call_args
- self.assertEqual(args[0], "dev-1")
- finally:
- await g.stop()
-
- async def test_synthetic_room_view_event_handled(self):
- """Bridge-side `_perception_broadcast` after roster match emits
- an event tagged `data.source = "room_view"`. The greeter should
- treat it identically to a firmware-emitted face_recognized."""
- now = datetime(2026, 4, 21, 7, 30, tzinfo=ZoneInfo("Australia/Brisbane"))
- g, bus, llm, cal, tts = _greeter(fixed_now=now)
- synthetic = {
- "name": "face_recognized",
- "device_id": "dev-1",
- "ts": now.timestamp(),
- "data": {"identity": "Hudson", "source": "room_view"},
- }
- await g._handle(synthetic)
- self.assertEqual(tts.await_count, 1)
- _, text = tts.call_args.args
- self.assertIn("Hi there!", text)
-
-
-class TestHouseholdRegistryEnrichment(unittest.IsolatedAsyncioTestCase):
- """Greeter should pull display_name, persona, and birthday awareness
- from the household registry when one is wired in. Without a registry,
- it falls back to the raw identity string (legacy behaviour)."""
-
- def _registry_with(self, **kwargs):
- """Build a tiny in-memory stand-in for the registry. We don't
- need real YAML loading for greeter tests; we just need an object
- with a `.get(identity)` method that returns a Person-shaped
- object."""
- from bridge.household import Person # noqa: WPS433
-
- class _Stub:
- def __init__(self, person):
- self._person = person
-
- def get(self, identity):
- if not identity:
- return None
- if identity.lower() == self._person.id:
- return self._person
- return None
-
- return _Stub(Person(id="hudson", display_name="Hudson", **kwargs))
-
- def _greeter_with_registry(self, registry, **kwargs):
- from unittest.mock import AsyncMock
- bus = _FakeBus()
- cal = _FakeCalendar()
- llm = AsyncMock(return_value="Hi Hudson!")
- tts = AsyncMock()
- td = tempfile.TemporaryDirectory()
- self.addCleanup(td.cleanup)
- from datetime import datetime
- from zoneinfo import ZoneInfo
- now = kwargs.pop(
- "fixed_now",
- datetime(2026, 4, 21, 7, 30, tzinfo=ZoneInfo("Australia/Brisbane")),
- )
- g = ProactiveGreeter(
- perception_bus=bus,
- llm_client=llm,
- calendar_cache=cal,
- tts_pusher=tts,
- kid_mode_provider=lambda: True,
- household_registry=registry,
- clock=lambda: now.timestamp(),
- tz=now.tzinfo,
- )
- g._state_path = Path(td.name) / "s.json" # type: ignore[attr-defined]
- g._state = {} # type: ignore[attr-defined]
- return g, llm, tts
-
- async def test_display_name_used_in_prompt(self):
- reg = self._registry_with(age=7, personality="curious")
- g, llm, tts = self._greeter_with_registry(reg)
- prompt = g._build_prompt( # type: ignore[attr-defined]
- identity="hudson", window="morning", events=[],
- )
- self.assertIn("Hudson", prompt)
- self.assertIn("7yo", prompt)
- self.assertIn("curious", prompt)
-
- async def test_no_registry_falls_back_to_raw_identity(self):
- # Wire registry=None explicitly. Prompt should still produce a
- # working greeting using the identity string verbatim.
- from unittest.mock import AsyncMock
- from datetime import datetime
- from zoneinfo import ZoneInfo
- now = datetime(2026, 4, 21, 7, 30, tzinfo=ZoneInfo("Australia/Brisbane"))
- td = tempfile.TemporaryDirectory()
- self.addCleanup(td.cleanup)
- g = ProactiveGreeter(
- perception_bus=_FakeBus(),
- llm_client=AsyncMock(return_value="Hi!"),
- calendar_cache=_FakeCalendar(),
- tts_pusher=AsyncMock(),
- kid_mode_provider=lambda: True,
- household_registry=None, # explicit
- clock=lambda: now.timestamp(),
- tz=now.tzinfo,
- )
- prompt = g._build_prompt( # type: ignore[attr-defined]
- identity="raw_identity", window="morning", events=[],
- )
- self.assertIn("raw_identity", prompt)
- self.assertNotIn("About raw_identity", prompt) # no enrichment block
-
- async def test_birthday_today_acknowledged_in_prompt(self):
- # Build a registry whose `days_until_birthday()` returns 0
- # regardless of real-world date, so the test isn't a flake.
-
- class _ZeroDayPerson:
- id = "hudson"
- display_name = "Hudson"
-
- def compact_description(self, **kwargs):
- return "Hudson — 7yo"
-
- def days_until_birthday(self, **kwargs):
- return 0
-
- class _Reg:
- def get(self, identity):
- return _ZeroDayPerson() if identity == "hudson" else None
-
- g, _, _ = self._greeter_with_registry(_Reg())
- prompt = g._build_prompt( # type: ignore[attr-defined]
- identity="hudson", window="morning", events=[],
- )
- self.assertIn("birthday today", prompt)
-
- async def test_birthday_in_a_few_days_mentioned_lightly(self):
- class _SoonPerson:
- id = "hudson"
- display_name = "Hudson"
-
- def compact_description(self, **kwargs):
- return "Hudson — 7yo"
-
- def days_until_birthday(self, **kwargs):
- return 3
-
- class _Reg:
- def get(self, identity):
- return _SoonPerson() if identity == "hudson" else None
-
- g, _, _ = self._greeter_with_registry(_Reg())
- prompt = g._build_prompt( # type: ignore[attr-defined]
- identity="hudson", window="morning", events=[],
- )
- self.assertIn("3 days", prompt)
- self.assertIn("if it fits", prompt)
-
- async def test_unknown_identity_no_enrichment(self):
- reg = self._registry_with(age=7)
- g, _, _ = self._greeter_with_registry(reg)
- prompt = g._build_prompt( # type: ignore[attr-defined]
- identity="unknown", window="morning", events=[],
- )
- self.assertNotIn("Hudson", prompt)
- self.assertNotIn("7yo", prompt)
-
- async def test_template_fallback_uses_display_name(self):
- reg = self._registry_with(age=7)
- g, _, _ = self._greeter_with_registry(reg)
- out = g._template_fallback(identity="hudson", window="morning") # type: ignore[attr-defined]
- self.assertEqual(out, "Good morning, Hudson!")
-
- async def test_registry_exception_is_swallowed(self):
- class _Broken:
- def get(self, identity):
- raise RuntimeError("registry broken")
-
- g, _, _ = self._greeter_with_registry(_Broken())
- # Must not raise; falls back to raw identity.
- prompt = g._build_prompt( # type: ignore[attr-defined]
- identity="hudson", window="morning", events=[],
- )
- self.assertIn("hudson", prompt)
-
-
-if __name__ == "__main__":
- unittest.main()
diff --git a/tests/test_room_view_parser.py b/tests/test_room_view_parser.py
deleted file mode 100644
index 2e3bb2c..0000000
--- a/tests/test_room_view_parser.py
+++ /dev/null
@@ -1,134 +0,0 @@
-"""Unit tests for the room-view VLM response parser.
-
-Covers the v2 (DESC|NAME|MOOD) format introduced in commit 4 plus
-backward compat with v1 (DESC|NAME) replies and graceful degradation
-when the model breaks format entirely.
-
-Pure regex/string tests — does not import bridge.py (the parser is
-stateless; we replicate it locally to avoid the FastAPI ctor cost).
-Keep in sync with `_ROOM_VIEW_RESP_RE` and `_parse_room_view_response`
-in bridge.py.
-"""
-from __future__ import annotations
-
-import re
-import unittest
-
-
-_ROOM_VIEW_NO_PERSON = "no one in view"
-
-_ROOM_VIEW_RESP_RE = re.compile(
- r"^\s*DESC:\s*(?P.+?)\s*"
- r"\|\s*NAME:\s*(?P[A-Za-z_][A-Za-z0-9_-]*)\s*"
- r"(?:\|\s*MOOD:\s*(?P[A-Za-z]+)\s*)?"
- r"[.!?]?\s*$",
- re.IGNORECASE | re.DOTALL,
-)
-_ROOM_VIEW_MOODS = frozenset(
- {"engaged", "tired", "excited", "distressed", "neutral"}
-)
-
-
-def parse(raw: str, roster_ids: set[str]):
- if not raw:
- return None, None, None
- cleaned = raw.strip()
- if not cleaned:
- return None, None, None
- if _ROOM_VIEW_NO_PERSON in cleaned.lower():
- return None, None, None
- m = _ROOM_VIEW_RESP_RE.match(cleaned)
- if not m:
- return cleaned, None, None
- desc = m.group("desc").strip()
- name = m.group("name").strip().lower()
- raw_mood = (m.group("mood") or "").strip().lower()
- mood = raw_mood if raw_mood in _ROOM_VIEW_MOODS else None
- if not desc:
- desc = None
- if name == "unknown" or name not in roster_ids:
- return desc, None, mood
- return desc, name, mood
-
-
-ROSTER = {"hudson", "brett", "olivia"}
-
-
-class V2FormatTests(unittest.TestCase):
-
- def test_v2_full_match(self):
- desc, name, mood = parse(
- "DESC: a child with curly hair in a striped shirt | "
- "NAME: hudson | MOOD: engaged",
- ROSTER,
- )
- self.assertEqual(desc, "a child with curly hair in a striped shirt")
- self.assertEqual(name, "hudson")
- self.assertEqual(mood, "engaged")
-
- def test_v2_unknown_name_passes_mood_through(self):
- desc, name, mood = parse(
- "DESC: an adult man in a dark sweater | "
- "NAME: unknown | MOOD: tired",
- ROSTER,
- )
- self.assertIsNotNone(desc)
- self.assertIsNone(name)
- self.assertEqual(mood, "tired")
-
- def test_v2_off_roster_name_drops_to_unknown(self):
- desc, name, mood = parse(
- "DESC: a teen | NAME: zelda | MOOD: excited",
- ROSTER,
- )
- self.assertIsNone(name)
- self.assertEqual(mood, "excited")
-
- def test_v2_invalid_mood_drops_to_none(self):
- desc, name, mood = parse(
- "DESC: ok | NAME: hudson | MOOD: chaotic",
- ROSTER,
- )
- # Invalid moods are silently dropped — but invalid mood means
- # the optional group doesn't match, so the whole regex still
- # succeeds (mood=None) without breaking desc/name.
- self.assertIsNone(mood)
- self.assertEqual(desc, "ok")
- self.assertEqual(name, "hudson")
-
-
-class V1BackwardCompatTests(unittest.TestCase):
-
- def test_v1_format_still_parses(self):
- desc, name, mood = parse(
- "DESC: a child reading at the table | NAME: hudson",
- ROSTER,
- )
- self.assertEqual(desc, "a child reading at the table")
- self.assertEqual(name, "hudson")
- self.assertIsNone(mood)
-
-
-class GracefulDegradeTests(unittest.TestCase):
-
- def test_no_one_in_view(self):
- d, n, m = parse("no one in view", ROSTER)
- self.assertEqual((d, n, m), (None, None, None))
-
- def test_format_break_falls_back_to_raw_desc(self):
- # Some VLM replies skip the markers entirely. We still want the
- # description signal — preferable to losing it for format strictness.
- d, n, m = parse("A child playing with lego.", ROSTER)
- self.assertEqual(d, "A child playing with lego.")
- self.assertIsNone(n)
- self.assertIsNone(m)
-
- def test_empty_string(self):
- self.assertEqual(parse("", ROSTER), (None, None, None))
-
- def test_whitespace_only(self):
- self.assertEqual(parse(" \n\n ", ROSTER), (None, None, None))
-
-
-if __name__ == "__main__":
- unittest.main()
diff --git a/tests/test_speaker.py b/tests/test_speaker.py
deleted file mode 100644
index b0ee291..0000000
--- a/tests/test_speaker.py
+++ /dev/null
@@ -1,453 +0,0 @@
-"""Unit tests for `bridge.speaker.SpeakerResolver`.
-
-Pure-unit tests — no network, no filesystem. All time and calendar
-inputs are injected via fakes so the tests are deterministic.
-"""
-from __future__ import annotations
-
-import sys
-import textwrap
-import unittest
-from datetime import datetime
-from pathlib import Path
-from zoneinfo import ZoneInfo
-
-sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
-
-from bridge.household import HouseholdRegistry # noqa: E402
-from bridge.speaker import ( # noqa: E402
- SIG_CALENDAR,
- SIG_PERCEPTION,
- SIG_SELF_ID,
- SIG_STICKY,
- SIG_TIME_OF_DAY,
- SpeakerResolution,
- SpeakerResolver,
-)
-
-
-TZ = ZoneInfo("Australia/Brisbane")
-
-
-def _registry(yaml_body: str) -> HouseholdRegistry:
- """Write a one-shot YAML file and load a registry. Uses tempfile so
- each test gets its own."""
- import os
- import tempfile
- fd, raw = tempfile.mkstemp(suffix=".yaml")
- os.close(fd)
- path = Path(raw)
- path.write_text(textwrap.dedent(yaml_body), encoding="utf-8")
- return HouseholdRegistry(path=path)
-
-
-def _ts(year: int, month: int, day: int, hour: int, minute: int = 0) -> float:
- return datetime(year, month, day, hour, minute, tzinfo=TZ).timestamp()
-
-
-HOUSEHOLD_YAML = """
-people:
- alex:
- display_name: Alex
- self_id_phrases: ["it's alex", "alex here"]
- calendar_prefix: "[Alex]"
- usual_times:
- weekdays: [evening, night]
- weekends: [any]
- sam:
- display_name: Sam
- self_id_phrases: ["it's sam", "sam here"]
- calendar_prefix: "[Sam]"
- usual_times:
- weekdays: [after-school, early-evening]
- weekends: [morning, afternoon]
-"""
-
-
-class TestSelfIdSignal(unittest.TestCase):
- def test_self_id_match_dominates(self) -> None:
- reg = _registry(HOUSEHOLD_YAML)
- # Even with a strong calendar prior pointing at Sam, "It's Alex"
- # should latch the channel onto Alex.
- events = [{"person": "[Sam]", "summary": "Library day"}]
- clock = lambda: _ts(2026, 4, 25, 9, 0) # Saturday morning
- r = SpeakerResolver(
- registry=reg,
- calendar_provider=lambda: events,
- clock=clock,
- tz=TZ,
- )
- out = r.resolve("It's Alex, can you check my calendar?", channel="dotty")
- self.assertEqual(out.person_id, "alex")
- self.assertEqual(out.addressee, "Alex")
- self.assertGreaterEqual(out.confidence, 0.9)
- self.assertIn(SIG_SELF_ID, [v.signal for v in out.votes])
-
- def test_self_id_sets_sticky(self) -> None:
- reg = _registry(HOUSEHOLD_YAML)
- clock_t = [_ts(2026, 4, 25, 9, 0)]
- r = SpeakerResolver(
- registry=reg,
- clock=lambda: clock_t[0],
- tz=TZ,
- )
- first = r.resolve("It's Alex.", channel="dotty")
- self.assertEqual(first.person_id, "alex")
- # Advance time within the sticky window — next turn should still
- # resolve to Alex via the sticky signal.
- clock_t[0] += 60
- second = r.resolve("How are you today?", channel="dotty")
- self.assertEqual(second.person_id, "alex")
- signals = {v.signal for v in second.votes}
- self.assertIn(SIG_STICKY, signals)
- self.assertNotIn(SIG_SELF_ID, signals)
-
- def test_sticky_expires(self) -> None:
- reg = _registry(HOUSEHOLD_YAML)
- clock_t = [_ts(2026, 4, 25, 9, 0)]
- r = SpeakerResolver(
- registry=reg,
- clock=lambda: clock_t[0],
- tz=TZ,
- weights={
- # Suppress all priors so the absence of sticky is the
- # only thing controlling the outcome.
- SIG_CALENDAR: 0.0, SIG_TIME_OF_DAY: 0.0, SIG_PERCEPTION: 0.0,
- },
- )
- r.resolve("It's Alex.", channel="dotty")
- # Advance 2 hours — well past the default 600s sticky window.
- clock_t[0] += 7200
- out = r.resolve("Hi there!", channel="dotty")
- self.assertIsNone(out.person_id) # falls back
-
- def test_self_id_overrides_existing_sticky(self) -> None:
- reg = _registry(HOUSEHOLD_YAML)
- clock_t = [_ts(2026, 4, 25, 9, 0)]
- r = SpeakerResolver(registry=reg, clock=lambda: clock_t[0], tz=TZ)
- r.resolve("It's Alex.", channel="dotty")
- clock_t[0] += 60
- # Correction mid-conversation. self-ID matches at leading
- # position only ("wait, it's sam" would NOT trigger). The
- # natural way for a user to correct is to lead with the phrase.
- out = r.resolve("It's Sam, sorry — I confused you.", channel="dotty")
- self.assertEqual(out.person_id, "sam")
- signals = {v.signal for v in out.votes}
- self.assertIn(SIG_SELF_ID, signals)
-
- def test_per_channel_sticky_isolation(self) -> None:
- reg = _registry(HOUSEHOLD_YAML)
- r = SpeakerResolver(
- registry=reg,
- clock=lambda: _ts(2026, 4, 25, 9, 0),
- tz=TZ,
- weights={
- SIG_CALENDAR: 0.0, SIG_TIME_OF_DAY: 0.0, SIG_PERCEPTION: 0.0,
- },
- )
- r.resolve("It's Alex.", channel="dotty")
- # Discord channel sees no sticky for Alex.
- out = r.resolve("Hi", channel="discord")
- self.assertIsNone(out.person_id)
- # But the Dotty channel still has Alex.
- out_dotty = r.resolve("Hi", channel="dotty")
- self.assertEqual(out_dotty.person_id, "alex")
-
-
-class TestCalendarSignal(unittest.TestCase):
- def test_calendar_within_window(self) -> None:
- reg = _registry(HOUSEHOLD_YAML)
- # Saturday 09:00 — Alex has [any] weekend usual_times so he'd
- # also win the time-of-day prior. Use a weekday with no usual
- # times for either, so calendar is the only voter.
- # Tuesday 12:00. Sam's usual_times[weekdays] doesn't include
- # 'afternoon' (his is after-school), Alex's [evening, night]
- # neither — clean slate.
- clock = lambda: _ts(2026, 4, 21, 12, 0)
- # Event tagged [Sam] starting in 15 min.
- ev_start = datetime.fromtimestamp(clock() + 15 * 60, tz=TZ).isoformat()
- events = [{
- "person": "[Sam]",
- "summary": "Library day",
- "start_iso": ev_start,
- }]
- r = SpeakerResolver(
- registry=reg,
- calendar_provider=lambda: events,
- clock=clock,
- tz=TZ,
- )
- out = r.resolve("Hi there!", channel="dotty")
- self.assertEqual(out.person_id, "sam")
- self.assertIn(SIG_CALENDAR, [v.signal for v in out.votes])
-
- def test_calendar_outside_window_ignored(self) -> None:
- reg = _registry(HOUSEHOLD_YAML)
- clock = lambda: _ts(2026, 4, 21, 12, 0)
- # Event 3 hours away — well outside the default 30 min window.
- ev_start = datetime.fromtimestamp(clock() + 3 * 3600, tz=TZ).isoformat()
- events = [{
- "person": "[Sam]",
- "summary": "Library day",
- "start_iso": ev_start,
- }]
- r = SpeakerResolver(
- registry=reg,
- calendar_provider=lambda: events,
- clock=clock,
- tz=TZ,
- )
- out = r.resolve("Hi there!", channel="dotty")
- # Sam should NOT be picked just from calendar alone.
- signals_for_sam = [
- v for v in out.votes if v.person_id == "sam" and v.signal == SIG_CALENDAR
- ]
- self.assertEqual(signals_for_sam, [])
-
- def test_calendar_household_tag_skipped(self) -> None:
- reg = _registry(HOUSEHOLD_YAML)
- clock = lambda: _ts(2026, 4, 21, 12, 0)
- ev_start = datetime.fromtimestamp(clock() + 5 * 60, tz=TZ).isoformat()
- events = [{
- "person": "_household",
- "summary": "Family dinner",
- "start_iso": ev_start,
- }]
- r = SpeakerResolver(
- registry=reg, calendar_provider=lambda: events,
- clock=clock, tz=TZ,
- )
- out = r.resolve("Hi", channel="dotty")
- cal_votes = [v for v in out.votes if v.signal == SIG_CALENDAR]
- self.assertEqual(cal_votes, [])
-
- def test_calendar_provider_exception_swallowed(self) -> None:
- reg = _registry(HOUSEHOLD_YAML)
-
- def _broken():
- raise RuntimeError("calendar API down")
-
- r = SpeakerResolver(
- registry=reg, calendar_provider=_broken,
- clock=lambda: _ts(2026, 4, 21, 12, 0), tz=TZ,
- )
- # Must not raise.
- out = r.resolve("Hi there!", channel="dotty")
- self.assertIsInstance(out, SpeakerResolution)
-
-
-class TestTimeOfDaySignal(unittest.TestCase):
- def test_after_school_picks_kid(self) -> None:
- reg = _registry(HOUSEHOLD_YAML)
- # Tuesday 16:00 — Sam's `weekdays: [after-school, early-evening]`.
- clock = lambda: _ts(2026, 4, 21, 16, 0)
- r = SpeakerResolver(registry=reg, clock=clock, tz=TZ)
- out = r.resolve("hi", channel="dotty")
- self.assertEqual(out.person_id, "sam")
- self.assertIn(SIG_TIME_OF_DAY, [v.signal for v in out.votes])
-
- def test_evening_picks_parent(self) -> None:
- reg = _registry(HOUSEHOLD_YAML)
- # Tuesday 21:00 — Alex's `weekdays: [evening, night]`.
- clock = lambda: _ts(2026, 4, 21, 21, 0)
- r = SpeakerResolver(registry=reg, clock=clock, tz=TZ)
- out = r.resolve("hi", channel="dotty")
- self.assertEqual(out.person_id, "alex")
-
- def test_any_keyword_matches_any_bucket(self) -> None:
- # Alex on weekends: [any]. Saturday 14:00 should match.
- reg = _registry(HOUSEHOLD_YAML)
- clock = lambda: _ts(2026, 4, 25, 14, 0)
- r = SpeakerResolver(registry=reg, clock=clock, tz=TZ)
- out = r.resolve("hi", channel="dotty")
- # Both Alex (weekends:[any]) and Sam (weekends:[morning,afternoon])
- # match. Tie on weight; either is acceptable, but Alex MUST be
- # in the votes.
- votes_alex = [v for v in out.votes if v.person_id == "alex"]
- self.assertTrue(votes_alex, "Alex should have a time-of-day vote on Saturday")
-
- def test_no_usual_times_no_signal(self) -> None:
- reg = _registry("""
- people:
- x:
- display_name: X
- """)
- clock = lambda: _ts(2026, 4, 21, 16, 0)
- r = SpeakerResolver(registry=reg, clock=clock, tz=TZ)
- out = r.resolve("hi", channel="dotty")
- signals = [v.signal for v in out.votes]
- self.assertNotIn(SIG_TIME_OF_DAY, signals)
-
-
-class TestPerceptionSignal(unittest.TestCase):
- def test_face_recognized_within_window(self) -> None:
- reg = _registry(HOUSEHOLD_YAML)
- clock = lambda: _ts(2026, 4, 25, 9, 0)
- events = [{
- "name": "face_recognized",
- "ts": clock() - 5, # 5 seconds ago
- "data": {"identity": "alex"},
- }]
- r = SpeakerResolver(
- registry=reg, perception_provider=lambda: events,
- clock=clock, tz=TZ,
- weights={
- SIG_CALENDAR: 0.0, SIG_TIME_OF_DAY: 0.0,
- },
- )
- out = r.resolve("hi", channel="dotty")
- self.assertEqual(out.person_id, "alex")
- self.assertIn(SIG_PERCEPTION, [v.signal for v in out.votes])
-
- def test_face_recognized_stale_ignored(self) -> None:
- reg = _registry(HOUSEHOLD_YAML)
- clock = lambda: _ts(2026, 4, 25, 9, 0)
- events = [{
- "name": "face_recognized",
- "ts": clock() - 600, # 10 minutes ago — past the window
- "data": {"identity": "alex"},
- }]
- r = SpeakerResolver(
- registry=reg, perception_provider=lambda: events,
- clock=clock, tz=TZ,
- weights={
- SIG_CALENDAR: 0.0, SIG_TIME_OF_DAY: 0.0,
- },
- )
- out = r.resolve("hi", channel="dotty")
- self.assertIsNone(out.person_id)
-
- def test_face_detected_no_identity_no_vote(self) -> None:
- reg = _registry(HOUSEHOLD_YAML)
- clock = lambda: _ts(2026, 4, 25, 9, 0)
- events = [{
- "name": "face_detected",
- "ts": clock() - 5,
- "data": {},
- }]
- r = SpeakerResolver(
- registry=reg, perception_provider=lambda: events,
- clock=clock, tz=TZ,
- weights={
- SIG_CALENDAR: 0.0, SIG_TIME_OF_DAY: 0.0,
- },
- )
- out = r.resolve("hi", channel="dotty")
- # face_detected without identity must not invent a person.
- self.assertIsNone(out.person_id)
-
-
-class TestCombiner(unittest.TestCase):
- def test_no_signals_yields_fallback(self) -> None:
- reg = _registry(HOUSEHOLD_YAML)
- # Tuesday 12:00 — neither Alex nor Sam have usual_times here.
- # No calendar, no perception, no self-ID, no sticky.
- clock = lambda: _ts(2026, 4, 21, 12, 0)
- r = SpeakerResolver(registry=reg, clock=clock, tz=TZ)
- out = r.resolve("Hi there", channel="dotty")
- self.assertIsNone(out.person_id)
- self.assertEqual(out.addressee, "_household")
-
- def test_low_confidence_triggers_ask(self) -> None:
- # Single time-of-day vote (weight 0.15) is below the default
- # 0.5 ask threshold and should trigger ask_clarification.
- reg = _registry(HOUSEHOLD_YAML)
- clock = lambda: _ts(2026, 4, 21, 16, 0) # Tue 16:00 → Sam
- r = SpeakerResolver(registry=reg, clock=clock, tz=TZ)
- out = r.resolve("hi", channel="dotty")
- self.assertEqual(out.person_id, "sam")
- self.assertLess(out.confidence, 0.5)
- self.assertTrue(out.ask_clarification)
-
- def test_self_id_does_not_trigger_ask(self) -> None:
- reg = _registry(HOUSEHOLD_YAML)
- r = SpeakerResolver(registry=reg, clock=lambda: _ts(2026, 4, 21, 12, 0), tz=TZ)
- out = r.resolve("It's Alex.", channel="dotty")
- self.assertFalse(out.ask_clarification)
-
- def test_runner_up_reported(self) -> None:
- # Tie-ish situation: weekday 16:00, both Alex (no match) and Sam
- # (after-school match). With manual weights we can force a tie.
- reg = _registry(HOUSEHOLD_YAML)
- clock = lambda: _ts(2026, 4, 21, 16, 0)
- ev_start = datetime.fromtimestamp(clock() + 5 * 60, tz=TZ).isoformat()
- events = [{
- "person": "[Alex]",
- "summary": "Quick errand",
- "start_iso": ev_start,
- }]
- r = SpeakerResolver(
- registry=reg, calendar_provider=lambda: events,
- clock=clock, tz=TZ,
- )
- out = r.resolve("hi", channel="dotty")
- # Both signals fire; whoever wins, the other is the runner-up.
- candidates = {out.person_id, out.runner_up_id}
- self.assertIn("alex", candidates)
- self.assertIn("sam", candidates)
-
-
-class TestForceSetSticky(unittest.TestCase):
- def test_force_set_takes_effect(self) -> None:
- reg = _registry(HOUSEHOLD_YAML)
- r = SpeakerResolver(
- registry=reg, clock=lambda: _ts(2026, 4, 21, 12, 0), tz=TZ,
- weights={
- SIG_CALENDAR: 0.0, SIG_TIME_OF_DAY: 0.0, SIG_PERCEPTION: 0.0,
- },
- )
- # Initial: no signals -> fallback.
- self.assertIsNone(r.resolve("hi", channel="dotty").person_id)
- # Portal/test override.
- r.force_set_sticky("dotty", None, "alex")
- out = r.resolve("hi", channel="dotty")
- self.assertEqual(out.person_id, "alex")
- self.assertEqual(out.addressee, "Alex")
-
- def test_clear_sticky(self) -> None:
- reg = _registry(HOUSEHOLD_YAML)
- r = SpeakerResolver(
- registry=reg, clock=lambda: _ts(2026, 4, 21, 12, 0), tz=TZ,
- weights={
- SIG_CALENDAR: 0.0, SIG_TIME_OF_DAY: 0.0, SIG_PERCEPTION: 0.0,
- },
- )
- r.resolve("It's Alex.", channel="dotty")
- self.assertEqual(r.peek_sticky("dotty", None), "alex")
- r.clear_sticky("dotty", None)
- self.assertIsNone(r.peek_sticky("dotty", None))
-
-
-class TestAuditHook(unittest.TestCase):
- def test_audit_hook_called(self) -> None:
- reg = _registry(HOUSEHOLD_YAML)
- captured = []
- r = SpeakerResolver(registry=reg, clock=lambda: _ts(2026, 4, 21, 12, 0), tz=TZ)
- r.set_audit_hook(lambda res, ch, txt: captured.append((res, ch, txt)))
- r.resolve("It's Alex.", channel="dotty")
- self.assertEqual(len(captured), 1)
- res, ch, txt = captured[0]
- self.assertEqual(res.person_id, "alex")
- self.assertEqual(ch, "dotty")
- self.assertIn("Alex", txt)
-
- def test_audit_hook_exception_swallowed(self) -> None:
- reg = _registry(HOUSEHOLD_YAML)
- r = SpeakerResolver(registry=reg, clock=lambda: _ts(2026, 4, 21, 12, 0), tz=TZ)
- r.set_audit_hook(lambda *args, **kw: (_ for _ in ()).throw(RuntimeError("x")))
- # Must not raise.
- out = r.resolve("It's Alex.", channel="dotty")
- self.assertEqual(out.person_id, "alex")
-
-
-class TestNoRegistry(unittest.TestCase):
- def test_resolver_without_registry_falls_back(self) -> None:
- r = SpeakerResolver(registry=None, clock=lambda: _ts(2026, 4, 21, 12, 0), tz=TZ)
- out = r.resolve("It's Alex.", channel="dotty")
- # No registry → no self-ID match → fallback.
- self.assertIsNone(out.person_id)
- self.assertEqual(out.addressee, "_household")
-
-
-if __name__ == "__main__": # pragma: no cover
- unittest.main()
diff --git a/zeroclaw-bridge.service.template b/zeroclaw-bridge.service.template
deleted file mode 100644
index 4ca89c6..0000000
--- a/zeroclaw-bridge.service.template
+++ /dev/null
@@ -1,48 +0,0 @@
-[Unit]
-Description=ZeroClaw HTTP Bridge for StackChan
-After=network-online.target
-Wants=network-online.target
-
-[Service]
-# EDIT: replace /root/... paths with wherever you cloned zeroclaw-bridge
-# and installed the zeroclaw binary. These defaults assume running as root
-# with cargo-installed zeroclaw under /root/.cargo.
-Type=simple
-User=root
-WorkingDirectory=/root/zeroclaw-bridge
-# Vision/LLM API keys (issue #15). Put OPENROUTER_API_KEY / VISION_API_KEY /
-# VLM_API_KEY here so they're loaded into the bridge process; without them
-# the bridge logs an ERROR and refuses to confabulate descriptions on
-# `take_photo` intents. Leading `-` makes the file optional (won't fail
-# startup if absent — the bridge will surface the missing-key ERROR
-# loudly on the first photo call instead).
-EnvironmentFile=-/root/zeroclaw-bridge/.env
-Environment=ZEROCLAW_BIN=/root/.cargo/bin/zeroclaw
-# Voice-daemon LLM tier identities. Bridge uses these in multi-profile mode
-# (set via VOICE_LOCAL_PROFILE_KEY / VOICE_CLOUD_PROFILE_KEY below) to flip
-# `[providers].fallback` between local llama-swap and OpenRouter on smart_mode.
-Environment=DOTTY_DEFAULT_MODEL=qwen3.5:4b
-# 27B cold-load on llama-swap (UD-Q4_K_XL @ 96K) is ~41s. Bridge's
-# voice think_hard tool dispatches a direct call to qwen3.6:27b — bump
-# this if you push 27B context higher or run on slower hardware.
-Environment=VOICE_THINKER_TIMEOUT=90
-Environment=SMART_MODEL=anthropic/claude-sonnet-4-6
-Environment=VOICE_LOCAL_PROFILE_KEY=custom:http://:8080/v1
-Environment=VOICE_CLOUD_PROFILE_KEY=custom:https://openrouter.ai/api/v1
-# Mirror of xiaozhi-server's PERSONA env var so the dashboard can show what's
-# active. Keep these two in sync; the actual persona load happens in the
-# xiaozhi-server container (PERSONA env in docker-compose.yml).
-Environment=DOTTY_ACTIVE_PERSONA=default
-# Optional: context injection for voice turns (date/time is always injected)
-# Environment=WEATHER_LOCATION=Brisbane
-# Environment=TZ=Australia/Brisbane
-# Environment=CALENDAR_ID=,
-# Environment=CALENDAR_SA_PATH=/root/.zeroclaw/secrets/google-calendar-sa.json
-ExecStart=/root/zeroclaw-bridge/.venv/bin/python /root/zeroclaw-bridge/bridge.py
-Restart=on-failure
-RestartSec=5
-StandardOutput=journal
-StandardError=journal
-
-[Install]
-WantedBy=multi-user.target