Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 86dcd99de2 | |||
| 554181ccbd | |||
| 58635f1a69 | |||
| fd3d063e02 |
@@ -77,8 +77,8 @@ Pure-Go packages (`router`, `memory`, `mavweb`, …) run under a plain `go test
|
||||
| `mavweb` | HTTP UI + PWA (`/dash`, `/history`, `/trace`, `/notifications`, `/tools`); WebAuthn auth. Connects to mavend's socket. |
|
||||
| `mavsttd` | Speech-to-text (whisper.cpp, CGO). |
|
||||
| `mavttsd` | Text-to-speech (piper subprocess). |
|
||||
| `mavwaked` | Wake-word / VAD gate. |
|
||||
| `mavenclient` | Voice loop client (mic → stt → core → tts). |
|
||||
| `mavwaked` | Wake-word / VAD gate. **Not on homesrv** — see below. |
|
||||
| `mavenclient` | Voice loop client (mic → stt → core → tts). **Not on homesrv** — see below. |
|
||||
| `mavpoll` | Telegram long-poll reach. |
|
||||
| `mavcaldav` | CalDAV calendar sync. |
|
||||
| `mavmaild` | Mail reader (IMAP, read-only). Holds the IMAP password; core never sees it. |
|
||||
@@ -87,6 +87,15 @@ Daemons are wired socket-to-socket, not linked. `internal/ipc` is the client/ser
|
||||
protocol; the config in `deploy/mavend.json` (with `${VAR}` env expansion from gitignored
|
||||
`deploy/telegram.env`) sets socket paths, model paths, and the phraser/embedder blocks.
|
||||
|
||||
**Seven of the nine run on homesrv. `mavwaked` and `mavenclient` do not, and that is the
|
||||
decision, not an oversight** (Vikunja #463, `docs/plans/17-where-the-voice-loop-runs.md`).
|
||||
homesrv has a microphone — it is a laptop — but it is in the wrong room, so a wake-word
|
||||
daemon there listens to nobody. They belong on a client machine where he is standing.
|
||||
`ipc.Dial` already takes `tcp://host:port?token=...` through the netaddr seam, so nothing
|
||||
needs building to allow it, but no such machine exists yet. **The consequence: the wake
|
||||
word and the VAD gate are covered by unit tests and by nothing else, and no amount of
|
||||
sitting at the box changes that.** Push-to-talk through `/dash` is what QA actually covers.
|
||||
|
||||
## The ecosystem: Nexus, Praxis, Hexis
|
||||
|
||||
Maven is one of four services. It owns conversation and personal memory. It does not
|
||||
|
||||
@@ -103,9 +103,14 @@ query path.
|
||||
```
|
||||
|
||||
`Recognizes()` requires both `enabled` and a `model_path`, so a half-filled block reads as off
|
||||
rather than as a capability that fails every turn. With `enabled` and no model the daemon still
|
||||
attaches the three methods — profiles can be created, listed and deleted — and logs that
|
||||
recognition is blocked.
|
||||
rather than as a capability that fails every turn.
|
||||
|
||||
That gate was written, documented, and then never called. It is called now, and the behaviour it
|
||||
describes changed with it. `enabled` with no `model_path` used to attach all three methods and log
|
||||
that enrolment was on. Today `newSpeakerWiring` returns nil, so the methods are absent, and the log
|
||||
says why: there is nothing to embed with, so enrol, list and forget would all be no-ops. That is
|
||||
the one config shape where the operator most needs to be told otherwise, and it was the shape that
|
||||
lied.
|
||||
|
||||
## Still open
|
||||
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
# Plan: The work board surface
|
||||
|
||||
**The decision Vikunja #431 asked for. Written 04-08-2026.**
|
||||
|
||||
**Verdict: build it, in a smaller shape than the task imagined.** The board is worth
|
||||
moving out of the file. The intake form belongs on the `/tasks` page, not on the voice
|
||||
path. The argument is not built, now or later.
|
||||
|
||||
## Why it is worth building
|
||||
|
||||
The reason is the one the task gives and it holds: company rules forbid pointing Claude
|
||||
at work repos, and Maven is the one assistant on the box that work material may reach.
|
||||
No telemetry, no cloud model, no third-party account. That is not a preference here, it
|
||||
is the whole permission.
|
||||
|
||||
The build is also small, because most of it landed already:
|
||||
|
||||
| Piece | Where | State |
|
||||
|---|---|---|
|
||||
| task rows, dedupe, status lifecycle | `internal/store/migrations.go:149` and migration #15 | done |
|
||||
| capture from speech, urgency stripped | `router.ParseTaskCapture`, `router.TaskCaptureGrammar` | done |
|
||||
| recite the list on request | `router.IsTaskListQuery` | done |
|
||||
| a page to read and change the board | `/tasks` in `cmd/mavweb` | done |
|
||||
| counting a shape without judging it | `internal/memory/behavior.go` | done, as precedent |
|
||||
| a proposal he reads when he chooses | `/routines`, the proposed-routine queue | done, as precedent |
|
||||
|
||||
`tasks` already carries `status` (candidate, open, done, dropped), `due_ts`, `weight`,
|
||||
`source`, `evidence`, `ext_id` and `resolved_by`. Three things are missing. It has no
|
||||
definition of done and no blocked-on. There is no way to edit a task after capture:
|
||||
`SetTaskStatus` moves the status and nothing writes text, date or weight again. And
|
||||
there is no grouping he controls, because order is computed by `tasks.Rank` alone.
|
||||
|
||||
## Where the form lives, and why not voice
|
||||
|
||||
The task asks the form to refuse a capture with no definition of done. That refusal
|
||||
cannot live on the voice path, for two reasons.
|
||||
|
||||
**The parked-state mechanism is binary.** `resolveConfirm` in `cmd/mavend/confirm.go`
|
||||
answers yes or no against a slot with a 90-second life. Filling four fields over four
|
||||
turns is slot filling, which is a different mechanism and a new one. Nothing in the
|
||||
daemon does it today.
|
||||
|
||||
**The definition of done is the worst possible field to dictate.** It is the one string
|
||||
that has to be exact, because its whole purpose is to be unarguable later. Whisper
|
||||
transcribing a sentence of Russian work vocabulary is where exactness goes to die, and
|
||||
the capture path already had to strip a question mark that whisper invented.
|
||||
|
||||
So: voice captures a line and recites the list. The page is where a line becomes an
|
||||
item with a definition of done, a blocked-on and a date. A captured line lands as
|
||||
`candidate` and stays there until it is filled in, which is what `candidate` was for.
|
||||
|
||||
The refusal the task wants survives, moved: the page will not promote a candidate to
|
||||
`open` without a definition of done, the same way `ParseTaskCapture` will not file a
|
||||
marker with nothing after it. And the field must close on either outcome, so "it already
|
||||
works" counts as complete. A definition of done that only one result satisfies is a wish.
|
||||
|
||||
## Does the stage-0 trick stretch
|
||||
|
||||
The task asks this before any shape is committed to. It was checked. The answer is
|
||||
partly.
|
||||
|
||||
`TaskCaptureGrammar` matches every utterance and lets `ParseTaskCapture` decide inside
|
||||
`Build`, keeping the intent at `note` and leaving the frozen seven-intent contract alone.
|
||||
That trick stretches to **recite** and to **status change**: both are a marker plus a
|
||||
referent, both are a lookup, and a status change is a small closed verb set over a list
|
||||
he can see. It does not stretch to **intake**, because intake is not one utterance, and
|
||||
it does not need to, because intake moved to the page.
|
||||
|
||||
One cost to name. Each such grammar matches everything and runs its parser on every
|
||||
turn, ahead of the resident model. Two more of them is fine. A dozen would make stage 0
|
||||
a second router with no evaluation behind it, and at that point the frozen enum is the
|
||||
smaller problem.
|
||||
|
||||
## What is not built: the argument
|
||||
|
||||
Not now and not later behind a flag. The task is right about why, and
|
||||
`internal/memory/behavior.go` already argued it for habits: a 1.7B asked whether evidence
|
||||
proves anything will agree fluently and launder a guess into a decision. A wrong claim
|
||||
about his work, stated confidently, is the most expensive kind of wrong Maven can be.
|
||||
|
||||
The line is the same line behaviour memory drew. She may **count**:
|
||||
|
||||
- no state change in eleven days
|
||||
- blocked on a person, with no date
|
||||
- four of nine waiting on two people
|
||||
|
||||
Those are queries over rows. She may not assess whether a build proves anything, whether
|
||||
a blocker is real, or whether a task should be dropped.
|
||||
|
||||
## Persona
|
||||
|
||||
A progress tracker is a nag by default, and "not a nag" is hard. The line is already
|
||||
drawn twice in the codebase and it is drawn the same way here:
|
||||
|
||||
- A date he set becomes a reminder. He set it, so it is not her raising it.
|
||||
- A stall becomes a proposal he reads when he chooses, on a page, like `/routines`.
|
||||
- Ask what is on the board and she recites. She never opens with it.
|
||||
|
||||
The day plan is the place to watch. `tickLoop.dayPlan` reads calendar events, pending
|
||||
reminders and checklist facts, and it does not read tasks. Adding the board to the
|
||||
morning nudge is exactly the move that turns this into a nag, so the board goes on the
|
||||
page and into the answer when asked, and not into the unprompted morning message.
|
||||
|
||||
## The build, as tasks
|
||||
|
||||
1. Two columns on `tasks`: definition of done, and blocked-on. Blocked-on resolves
|
||||
through Nexus like any other person reference, because identity lives in Nexus.
|
||||
2. An edit path. Today a task is write-once except for its status, so the form has
|
||||
nothing to save into.
|
||||
3. `/tasks` grows the form: promote candidate to open only with a definition of done,
|
||||
set a date, set blocked-on. A date set here writes a reminder.
|
||||
4. A status-change grammar at stage 0, following `TaskCaptureGrammar`.
|
||||
5. Counted stall shapes on `/tasks`, phrased as counts. No assessment.
|
||||
|
||||
Note for whoever picks up 3: `/tasks` accepts its POST without the step-up gate, while
|
||||
`/routines` and `/tools` require a passkey. That was deliberate for capture. Adding an
|
||||
edit path is the moment to re-argue it, not to inherit it silently.
|
||||
|
||||
Each is separable and each is worth stopping after.
|
||||
@@ -0,0 +1,87 @@
|
||||
# Plan: What the ambient calendar path should be
|
||||
|
||||
**The decision Vikunja #432 asked for. Written 04-08-2026.**
|
||||
|
||||
**Verdict: keep the endpoint, change the contract.** The relay app sends structured
|
||||
fields, not a notification blob. The free-text parser stays as the degraded path, because
|
||||
there is a real case where the phone cannot produce structure. Delete the endpoint only
|
||||
if the answer to the one open question below is no.
|
||||
|
||||
## First, the task's premise is out of date
|
||||
|
||||
#432 states as confirmed that every ambient event lands on the day the notification was
|
||||
posted, because there is no date parsing at all. That was true when the task was filed
|
||||
and it is not true now.
|
||||
|
||||
`4e4c917` added `dayWords` and `dayOffset` (`internal/calendar/ambient.go:53`), so
|
||||
"завтра в 15:00" now dates to tomorrow. The same commit added `ambientPastGrace`, which
|
||||
refuses an event landing more than two hours before the notification, on the reasoning
|
||||
that the day was inferred and a stale inference is wrong rather than late. `45a5e37`
|
||||
(#482, this week) fixed a second dating bug the task did not know about: the wall clock
|
||||
was resolved against the notification's own zone, so every ambient meeting on a non-UTC
|
||||
box landed off by the deploy's UTC offset.
|
||||
|
||||
What is still missing is an explicit date. "5 августа в 15:00" and "12.08 15:00" carry no
|
||||
day word, so they date to today and the past-grace check drops them. That is a smaller
|
||||
defect than the one filed, and it fails safe rather than storing a wrong meeting.
|
||||
|
||||
The task's third question also has an answer, and the answer is yes. `internal/morning/plan.go:174`
|
||||
prefixes an uncertain item with "похоже, " and `cmd/mavend/actions_query.go:334` carries
|
||||
`Confidence < 1.0` into the calendar recital. Both readers hedge.
|
||||
|
||||
## The open question, and it is the owner's
|
||||
|
||||
**Can the phone read Android's calendar provider, or only the notification text?**
|
||||
|
||||
Everything follows from this and nothing in this repo can answer it.
|
||||
|
||||
A `NotificationListenerService` sees a title and a body. It cannot know a meeting's real
|
||||
start, end or organiser, because those are not in the notification. So if the relay is
|
||||
limited to the notification stream, free-text parsing on this side is not a choice, it is
|
||||
the only thing available, and #432's suggestion that the phone send structured JSON
|
||||
cannot be honoured.
|
||||
|
||||
If the app may instead read `CalendarContract`, it has the actual event rows, and the
|
||||
whole parser stops being necessary. That is the better shape by a wide margin: a real
|
||||
start and end, a real title, an explicit date, no clock-reading heuristic and no
|
||||
past-grace guard, because nothing is inferred.
|
||||
|
||||
Reading the phone's calendar provider does not break the constraint the design was built
|
||||
around. The refusal in `internal/calendar/ambient.go:10` is about holding a work
|
||||
credential **on the homelab**, which is what ties the box's blast radius to the employer.
|
||||
The phone already holds that session. Nothing new lands on homesrv either way.
|
||||
|
||||
**Assumption, and it needs his answer:** a managed work profile may block a third-party
|
||||
app from reading work calendar rows. If it does, the notification stream is all there is.
|
||||
|
||||
## The decision
|
||||
|
||||
**Keep the endpoint.** Deleting it costs the parser, the tests and the wg-facing token,
|
||||
and buys nothing while the question above is open. It is off unless `-ambient-token` is
|
||||
set, so an unbuilt relay carries no surface today.
|
||||
|
||||
**Make structured the primary shape.** `/api/ambient` should accept an event with an
|
||||
explicit start, end and title, and store it without parsing anything. Confidence stays
|
||||
below 1.0 and the source stays `ambient:notif`, because the provenance claim is unchanged:
|
||||
this is the phone telling Maven what it sees, not Maven reading a calendar.
|
||||
|
||||
**Keep the free-text shape as the degraded path.** It is what a notification-only relay
|
||||
can send, and it is already written and tested.
|
||||
|
||||
**Delete it instead if** the relay is not going to be built. That is the one answer that
|
||||
closes this without code, and it is his to give.
|
||||
|
||||
## What not to do
|
||||
|
||||
Do not add date parsing to the free-text path yet. That is the patch #432 explicitly
|
||||
refuses to accept as closure, and it is the wrong order: if the relay can send a date, no
|
||||
date parser is needed, and if it cannot, the parser is guessing at a date from text that
|
||||
was never meant to carry one.
|
||||
|
||||
## Follow-on, unrelated to the decision
|
||||
|
||||
`cmd/mavweb/ambient.go:33` records a known gap worth keeping visible: an ambient meeting
|
||||
writes `calendar_event_*` and never `calendar_busy`, so it is good enough to recite and
|
||||
not good enough to suppress a nudge. That is backwards. Suppressing a nudge is the
|
||||
lower-risk use of a low-confidence signal, and reciting one is the higher-risk use. It
|
||||
needs an expiry on the busy level, so it is its own task either way.
|
||||
@@ -0,0 +1,60 @@
|
||||
# Plan: Where mavwaked and mavenclient run
|
||||
|
||||
**The decision Vikunja #463 asked for. Written 04-08-2026.**
|
||||
|
||||
**Verdict: not in compose on homesrv. They run on a client machine in the room he is in.**
|
||||
The transport for that already exists and nothing needs building to allow it. What needs
|
||||
building is a way to check the wake path at all, which is a separate task.
|
||||
|
||||
## The state that prompted this
|
||||
|
||||
`docker-compose.yml` runs mavend, mavsttd, mavttsd, mavweb and mavpoll. `mavwaked` and
|
||||
`mavenclient` appear in no compose file and run as no host process. Both build under
|
||||
`make build`. So the wake word and the voice-activity gate are untested by construction:
|
||||
QA session 1 step 2 covers push-to-talk from `/dash` only, and #287 (voice session
|
||||
quality) can never be more than half-answered while this holds.
|
||||
|
||||
## The reason is not hardware
|
||||
|
||||
homesrv has a microphone. It is a Lenovo IdeaPad 5 Pro, and `/proc/asound/cards` lists
|
||||
the ACP digital mic array with capture devices at `/dev/snd/pcmC1D0c` and
|
||||
`/dev/snd/pcmC2D0c`. Adding `/dev/snd` to compose and joining the `audio` group would
|
||||
work.
|
||||
|
||||
It would also be pointless. A wake-word daemon is worth having in the room he is standing
|
||||
in. homesrv is a server, so its microphone hears the room the server is in, which is not
|
||||
where anybody talks to Maven. Wiring audio into a container to listen to an empty room is
|
||||
work spent on a capability nobody can use.
|
||||
|
||||
## The reason it belongs off-box
|
||||
|
||||
`mavenclient` is a client by name and by design: microphone, then STT, then core, then
|
||||
TTS. It is the one binary in the tree meant to run somewhere else. `mavwaked` is the gate
|
||||
in front of it, so it goes wherever the microphone goes.
|
||||
|
||||
Maven already has components that are not containers on homesrv. `mavupdate` is a
|
||||
host-side tool. The resident model, STT and TTS prefer the workstation and fall back to
|
||||
homesrv (`docs/offload.md`). Off-box is a shape this system already has.
|
||||
|
||||
**And the wire already supports it.** `ipc.Dial` takes a netaddr seam address:
|
||||
a bare path is the unix socket, and `tcp://host:port?token=...` reaches a core on another
|
||||
host, with the token checked in `internal/netaddr` before `internal/ipc` sees the
|
||||
connection. So a client machine reaching mavend over wg or the LAN needs no new protocol
|
||||
work. It needs mavend to listen on TCP, which `deploy/mavend.json` does not currently ask
|
||||
for.
|
||||
|
||||
## What this means for the QA plan
|
||||
|
||||
QA session 1 step 2 should say what it actually covers, which is push-to-talk through
|
||||
`/dash`. It should not read as though it covers the voice loop. The wake path is checked
|
||||
on the client machine or it is not checked, and today there is no client machine.
|
||||
|
||||
That is the honest state, and it is worse than the task suggests: this is not a
|
||||
configuration gap that a compose entry closes. Until a machine with a microphone runs
|
||||
`mavwaked` and `mavenclient` against a TCP-listening mavend, `internal/wake` and
|
||||
`cmd/mavwaked`'s VAD are covered by their unit tests and by nothing else.
|
||||
|
||||
## What was wrong in CLAUDE.md
|
||||
|
||||
The daemon table lists all nine binaries with no column for where they run, which is how
|
||||
this went unnoticed for as long as it did. It now says which two are not on the box.
|
||||
+35
-9
@@ -1,6 +1,6 @@
|
||||
# QA plan: checking Maven properly
|
||||
|
||||
*Last verified: 2026-08-02 @ 20aa2d5. Living doc: correct it in place, do not append.*
|
||||
*Last verified: 2026-08-04 @ 58635f1. Living doc: correct it in place, do not append.*
|
||||
|
||||
Written 2026-08-01, after the 35-PR stack landed and the box came back up.
|
||||
Refreshed 2026-08-02 against the live list, after PRs #85-#90.
|
||||
@@ -105,9 +105,9 @@ turns look misaligned when they are not.
|
||||
**Passes** (02-08-2026, five turns): `я рада`, `поняла`, `помогла`,
|
||||
`проверила`, `записала`, `грустна`, `ты` throughout, no pet names.
|
||||
2. Press push-to-talk on `/dash`. Say `привет`. Confirm a spoken reply comes
|
||||
back. This is the only check that covers mic to STT to core to TTS to
|
||||
speaker as one path. It is also the path the eleven-day outage most likely
|
||||
broke.
|
||||
back. This covers browser mic to STT to core to TTS as one path. It does
|
||||
**not** cover the wake word or the voice-activity gate, and no step here
|
||||
does — see below.
|
||||
3. Say `тихий режим`. Expect `тихий режим включён. буду реже напоминать.` **Passes.**
|
||||
4. Say `выключи тихий режим`. Expect `тихий режим выключен.` Negation must win. **Passes.**
|
||||
5. Say `в комнате тихо`. Quiet mode must NOT flip. Confirm on `/history` that no
|
||||
@@ -129,11 +129,18 @@ turns look misaligned when they are not.
|
||||
and stitch unrelated topics. Asked whether he should move flats, she opened
|
||||
with the weather. That is 287, and it is a phrasing problem, not a loop problem.
|
||||
|
||||
**The wake path cannot be checked as deployed.** `mavwaked` and `mavenclient`
|
||||
appear in no compose file and run as no host process. Step 2 covers only
|
||||
push-to-talk, from `/dash` through mavsttd and mavttsd. Wake word and VAD
|
||||
are untested by construction. Decide whether they belong in compose or on a
|
||||
client machine, and say which in the deploy docs. Tracked as **463**.
|
||||
**The wake path cannot be checked here, and that is now the decision rather
|
||||
than a gap.** `mavwaked` and `mavenclient` appear in no compose file and run as
|
||||
no host process. They are not going to. They belong on a client machine in the
|
||||
room he is standing in, because homesrv's microphone is real and in the wrong
|
||||
room — **463**, written up in `docs/plans/17-where-the-voice-loop-runs.md`.
|
||||
|
||||
So the wake word and the VAD gate are covered by their unit tests and by
|
||||
nothing else, and no session at this box changes that. Checking them needs a
|
||||
machine with a microphone running both binaries against a TCP-listening mavend.
|
||||
`ipc.Dial` already speaks `tcp://host:port?token=...`, so the work is a machine
|
||||
and a config line, not protocol work. Until then, **287** can only be
|
||||
half-answered, and step 2 above is push-to-talk, not the voice loop.
|
||||
|
||||
**319's single-token bug is fixed** (01-08-2026). Single-word Russian utterances no longer come
|
||||
back as `не совсем поняла — можешь переформулировать?`. `привет` and `поужинал`
|
||||
@@ -471,6 +478,25 @@ at any address (**478**). `allow_private` does work, measured both ways.
|
||||
`CaptureStart`. There is no `cmd/mavheard`, no mavweb route, and `mavenclient`
|
||||
never calls it (**480**). Two of its QA steps are also stale.
|
||||
|
||||
**Four stale QA steps were rewritten on 04-08-2026** under **480**, against the
|
||||
code rather than against what the plans said. All four failed the same way: the
|
||||
daemon was right and the step described an older daemon.
|
||||
|
||||
| Step | Said | Says now |
|
||||
|---|---|---|
|
||||
| 253/3 | boots with the methods unknown | refuses to boot, `config.go:1651` |
|
||||
| 253/10 | no `:transcript` note by default | true only with a summary present |
|
||||
| 255/5 | `speaker: enrolment on, recognition BLOCKED` | that line is gone, the capability stays off |
|
||||
| 252/3 | `vision: stored image <id-prefix>` | `vision: stored <id>`, `vision.go:199` |
|
||||
|
||||
Two of them are worth reading past the correction. 253/10 was false in exactly
|
||||
the scenario 253/16 creates, because `writeNotes` saves the transcript whenever
|
||||
the summary is empty so a dead llama-server does not lose the meeting. And 255/5
|
||||
changed because `Recognizes()` was written as the gate, documented as one, and
|
||||
never called — calling it turned `enabled` with no model from a half-working
|
||||
capability into a refusal. Enrolling into a store nothing can match against is
|
||||
not a working half.
|
||||
|
||||
**257, netscan.** Steps 2, 3 and 9 pass at unit level. Step 1 fails. Steps 4 to 8
|
||||
need the block enabled. Step 10 is Bluetooth and stays skipped.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user