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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [Unreleased]

### Added
- **PersonResolver — one answer to "who is this?"** (`dotty-behaviour/household/resolver.py`) — identity resolution was smeared across consumers, and the 2026-06-06 audit found four separate identity bugs because of it. All resolution now funnels through one module with `Person.id` as the canonical key space. Fixed by the consolidation: **room_view roster recognition silently failing whenever `id != display_name`** (the VLM echoes display names; validation compared ids — confirmed 3/3, both the core and greeter paths), **multi-word display names never matching** (the NAME parser was single-token, so "Mary Anne" was a 100% silent miss — confirmed 3/3), **the greeter's calendar lookup dropping a person's own events on a case mismatch** (`[Hudson]` ≠ `hudson` — confirmed 2/3), and **bracketless `calendar_prefix:` YAML never matching**. `summarize_for_prompt` now matches person tags case-insensitively and accepts the resolver's tag set; the room_view test fakes were also fixed to carry real ids (the old fakes re-derived ids from display names, which is exactly what masked the bug).
- **Bridge systemd unit loads API keys from `${BRIDGE_DIR}/.env`** (#15) — `zeroclaw-bridge.service.template` and `scripts/install-bridge.sh` now emit `EnvironmentFile=-${BRIDGE_DIR}/.env`. `install-bridge.sh` creates a mode-0600 stub `.env` containing `OPENROUTER_API_KEY=` (and commented `VISION_API_KEY` / `VLM_API_KEY` placeholders) when one isn't already present, so the missing-vision-key failure surfaces as the bridge's existing ERROR ("camera offline") instead of a silent confabulation. Existing `.env` files are preserved.

### Changed
Expand Down
14 changes: 14 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -199,3 +199,17 @@ For hardware specs, protocol details, model internals, latent capabilities, and
- xiaozhi-esp32 firmware (upstream): https://github.com/78/xiaozhi-esp32
- StackChan (hardware + firmware patches): https://github.com/m5stack/StackChan
- Emotion protocol: https://xiaozhi.dev/en/docs/development/emotion/

## Agent skills

### Issue tracker

Issues live as GitHub issues on `BrettKinny/dotty-stackchan` (the `origin` remote), managed via the `gh` CLI. See `docs/agents/issue-tracker.md`.

### Triage labels

Five canonical triage roles use their default label strings (`needs-triage`, `needs-info`, `ready-for-agent`, `ready-for-human`, `wontfix`), orthogonal to the existing `status:*` / `area:*` labels. See `docs/agents/triage-labels.md`.

### Domain docs

Single-context: one `CONTEXT.md` + `docs/adr/` at the repo root. See `docs/agents/domain.md`.
38 changes: 38 additions & 0 deletions docs/agents/domain.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# Domain Docs

How the engineering skills should consume this repo's domain documentation when exploring the codebase.

This is a **single-context** repo: one `CONTEXT.md` + `docs/adr/` at the repo root cover the whole Dotty stack. (Note: `CLAUDE.md` at the root is the always-on architecture reference and the authoritative starting point regardless — `CONTEXT.md` is the lazily-grown domain glossary the producer skills maintain.)

## Before exploring, read these

- **`CONTEXT.md`** at the repo root.
- **`docs/adr/`** — read ADRs that touch the area you're about to work in.

If any of these files don't exist, **proceed silently**. Don't flag their absence; don't suggest creating them upfront. The producer skill (`/grill-with-docs`) creates them lazily when terms or decisions actually get resolved.

## File structure

Single-context repo:

```
/
├── CLAUDE.md ← always-on architecture reference (already exists)
├── CONTEXT.md ← domain glossary (created lazily by /grill-with-docs)
├── docs/adr/ ← architectural decision records (created lazily)
│ ├── 0001-....md
│ └── 0002-....md
└── ...
```

## Use the glossary's vocabulary

When your output names a domain concept (in an issue title, a refactor proposal, a hypothesis, a test name), use the term as defined in `CONTEXT.md`. Don't drift to synonyms the glossary explicitly avoids.

If the concept you need isn't in the glossary yet, that's a signal — either you're inventing language the project doesn't use (reconsider) or there's a real gap (note it for `/grill-with-docs`).

## Flag ADR conflicts

If your output contradicts an existing ADR, surface it explicitly rather than silently overriding:

> _Contradicts ADR-0007 (...) — but worth reopening because…_
32 changes: 32 additions & 0 deletions docs/agents/issue-tracker.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# Issue tracker: GitHub

Issues and PRDs for this repo live as GitHub issues on `BrettKinny/dotty-stackchan`. Use the `gh` CLI for all operations.

## Conventions

- **Create an issue**: `gh issue create --title "..." --body "..."`. Use a heredoc for multi-line bodies.
- **Read an issue**: `gh issue view <number> --comments`, filtering comments by `jq` and also fetching labels.
- **List issues**: `gh issue list --state open --json number,title,body,labels,comments --jq '[.[] | {number, title, body, labels: [.labels[].name], comments: [.comments[].body]}]'` with appropriate `--label` and `--state` filters.
- **Comment on an issue**: `gh issue comment <number> --body "..."`
- **Apply / remove labels**: `gh issue edit <number> --add-label "..."` / `--remove-label "..."`
- **Close**: `gh issue close <number> --comment "..."`

Infer the repo from `git remote -v` — `gh` does this automatically when run inside a clone. Note this clone has two remotes: `origin` → `BrettKinny/dotty-stackchan` (the canonical repo) and `fork` → `pboushy/dotty-stackchan`. Target `origin` unless told otherwise (e.g. `gh issue list --repo BrettKinny/dotty-stackchan`).

## Existing label conventions

This repo already uses workflow labels that the triage labels complement, not replace:

- `status:active`, `status:bench-pending`, `status:blocked`, `status:speculative` — lifecycle state (see the `dotty-task-runner` skill).
- `area:firmware`, `area:bridge`, `area:xiaozhi`, `area:dashboard`, `area:infra`, `area:docs`, `area:behaviour` — which part of the stack.
- `safety` — child-safety / correctness bug.

Keep applying these alongside the triage roles in `docs/agents/triage-labels.md`.

## When a skill says "publish to the issue tracker"

Create a GitHub issue.

## When a skill says "fetch the relevant ticket"

Run `gh issue view <number> --comments`.
19 changes: 19 additions & 0 deletions docs/agents/triage-labels.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Triage Labels

The skills speak in terms of five canonical triage roles. This file maps those roles to the actual label strings used in this repo's issue tracker.

| Label in mattpocock/skills | Label in our tracker | Meaning |
| -------------------------- | -------------------- | ---------------------------------------- |
| `needs-triage` | `needs-triage` | Maintainer needs to evaluate this issue |
| `needs-info` | `needs-info` | Waiting on reporter for more information |
| `ready-for-agent` | `ready-for-agent` | Fully specified, ready for an AFK agent |
| `ready-for-human` | `ready-for-human` | Requires human implementation |
| `wontfix` | `wontfix` | Will not be actioned |

`wontfix` already exists in this repo. The other four are created on first use (`gh label create <name>` or auto-created when applied via the API).

These triage roles are **orthogonal** to the repo's existing `status:*` lifecycle labels (`status:active`, `status:bench-pending`, `status:blocked`, `status:speculative`) and `area:*` labels — apply both as appropriate rather than treating them as alternatives.

When a skill mentions a role (e.g. "apply the AFK-ready triage label"), use the corresponding label string from this table.

Edit the right-hand column to match whatever vocabulary you actually use.
27 changes: 23 additions & 4 deletions dotty-behaviour/calendar_/cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@ class Event(TypedDict):


_EMAIL_RE = re.compile(r"\b[\w.+-]+@[\w-]+(?:\.[\w-]+)+\b")


def _fold_person_tag(value: str) -> str:
"""Case/whitespace-fold a person tag for comparison."""
return " ".join((value or "").lower().split())
_ISO_TS_RE = re.compile(
r"\b\d{4}-\d{2}-\d{2}"
r"(?:T\d{2}:\d{2}(?::\d{2})?(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?)?\b"
Expand Down Expand Up @@ -54,7 +59,7 @@ def bucket_by_person(events: list[Event]) -> dict[str, list[Event]]:
def summarize_for_prompt(
events: list[Event],
*,
person: str | None = None,
person: str | set[str] | None = None,
include_household: bool = True,
household_bucket: str = "_household",
) -> list[str]:
Expand All @@ -63,11 +68,25 @@ def summarize_for_prompt(
Strips ISO timestamps, emails, calendar IDs. Emits only short
`HH:MM summary` / `all-day summary` strings. Every prompt /
response that surfaces calendar data MUST go through here.

`person` filters to one person's events. It accepts either a single
name or a set of equivalent tags (id, display name, calendar
prefix — see PersonResolver.calendar_tags). Matching is case- and
whitespace-insensitive: the event's person comes from a free-typed
`[Name]` title prefix, so `[Hudson]` must match identity `hudson`
(audit 2026-06-06: the old exact compare dropped a person's own
events from their greeting).
"""
if person is None:
wanted: set[str] | None = None
elif isinstance(person, str):
wanted = {_fold_person_tag(person)}
else:
wanted = {_fold_person_tag(p) for p in person}
out: list[str] = []
for ev in events:
if person is not None:
if ev["person"] != person and not (
if wanted is not None:
if _fold_person_tag(ev["person"]) not in wanted and not (
include_household and ev["person"] == household_bucket
):
continue
Expand All @@ -77,7 +96,7 @@ def summarize_for_prompt(
clean_summary = " ".join(clean_summary.split())
if not clean_summary:
continue
if ev["person"] != household_bucket and person is None:
if ev["person"] != household_bucket and wanted is None:
tag = f"[{ev['person']}] "
else:
tag = ""
Expand Down
2 changes: 1 addition & 1 deletion dotty-behaviour/greeter/calendar_facade.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ def summarize_for_prompt(
self,
events: list[Any],
*,
person: str | None = None,
person: str | set[str] | None = None,
include_household: bool = True,
) -> list[str]:
return summarize_for_prompt(
Expand Down
25 changes: 14 additions & 11 deletions dotty-behaviour/greeter/greeter.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
from typing import Any, Awaitable, Callable, Optional
from zoneinfo import ZoneInfo

from household import PersonResolver
from perception import PerceptionEvent, PerceptionState

log = logging.getLogger("dotty-behaviour.greeter")
Expand Down Expand Up @@ -80,6 +81,7 @@ def __init__(
self._tts = tts_pusher
self._kid_mode = kid_mode_provider
self._household = household_registry
self._resolver = PersonResolver(household_registry)
self._clock = clock
tz_name = os.environ.get("TZ", "Australia/Brisbane")
try:
Expand Down Expand Up @@ -255,8 +257,16 @@ async def _generate_greeting(
events_summary: list[str] = []
try:
events = self._calendar.get_events()
# `identity` is a canonical person id; calendar events carry
# the free-typed `[Name]` title prefix. The resolver expands
# the id to every tag that means this person (id, display
# name, configured calendar_prefix) so their own events
# survive the case/name-space gap (audit 2026-06-06).
person_tags = self._resolver.calendar_tags(identity)
events_summary = self._calendar.summarize_for_prompt(
events, person=identity, include_household=True,
events,
person=person_tags or identity,
include_household=True,
) or []
except Exception:
log.warning(
Expand Down Expand Up @@ -326,16 +336,9 @@ def _build_prompt(
)

def _lookup_person(self, identity: str) -> Any:
if not self._household or not identity or identity == "unknown":
return None
try:
return self._household.get(identity)
except Exception:
log.debug(
"greeter: household lookup failed for %s",
identity, exc_info=True,
)
return None
# PersonResolver owns the id lookup (case fold, unknown/empty
# handling, exception safety).
return self._resolver.resolve(identity)

@staticmethod
def _post_process(text: str) -> str:
Expand Down
2 changes: 2 additions & 0 deletions dotty-behaviour/household/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,12 @@
HouseholdRegistry,
Person,
)
from .resolver import PersonResolver

__all__ = [
"DEFAULT_HOUSEHOLD_PATH",
"DEFAULT_PERSON_FALLBACK",
"HouseholdRegistry",
"Person",
"PersonResolver",
]
24 changes: 18 additions & 6 deletions dotty-behaviour/household/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -189,13 +189,13 @@ def roster_ids_with_appearance(self) -> set[str]:

def get_by_calendar_prefix(self, prefix: str) -> Optional[Person]:
"""Look up a person by their `[Name]` calendar prefix.
Case-insensitive; brackets optional."""
Case-insensitive; brackets optional — in the query AND in the
YAML (`calendar_prefix: Brett` and `calendar_prefix: "[Brett]"`
both work; the old code only matched the bracketed form)."""
self._reload_if_changed()
if not prefix:
key = _normalise_calendar_prefix(prefix)
if not key:
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

Expand Down Expand Up @@ -291,7 +291,9 @@ def _reload(self) -> None:
continue
people[person.id] = person
if person.calendar_prefix:
by_prefix[person.calendar_prefix.strip().lower()] = person.id
key = _normalise_calendar_prefix(person.calendar_prefix)
if key:
by_prefix[key] = person.id
for phrase in person.self_id_phrases:
norm = phrase.strip().lower()
if norm:
Expand Down Expand Up @@ -379,6 +381,16 @@ def _parse_person(raw_id: str, entry: Any) -> Optional[Person]:
)


def _normalise_calendar_prefix(value: str) -> str:
"""Canonical form for calendar-prefix keys: lowercase, trimmed,
brackets stripped. Used for both YAML storage and lookups so the
two can never disagree on bracket handling."""
key = (value or "").strip().lower()
if key.startswith("[") and key.endswith("]"):
key = key[1:-1].strip()
return key


def _opt_str(v: Any) -> Optional[str]:
if v is None:
return None
Expand Down
Loading
Loading