17 KiB
Overnight Session — 2026-07-06
Branch: overnight-jul6 (from master)
Executor: a single unsupervised agent working through the night.
READ THIS FIRST — Operating rules (do not skip)
You are working unsupervised. Optimize for not breaking anything over finishing every task. A half-finished task that compiles and is committed is a success; a clever half-rewrite that breaks the build is a failure.
Hard rules:
- One task = one commit. Never batch two tasks into one commit. Commit
message:
maven: <task-title> (task N). Sign-off line required (see repo convention — Co-Authored-By trailer). - TDD, always. For every task that touches Go: write the test first, watch
it fail, then write code until it passes. Tests live next to the code as
*_test.go. Copy the style of the nearest existing test file. - After every task, run the gate before committing:
If any step fails and you cannot fix it in ~15 min,
gofmt -l . # must print nothing go build ./... # must succeed go vet ./... # must be clean go test ./... # must be greengit stashor revert that task, write a note in the task's Status cell ("BLOCKED: "), and move to the next task. Do not leave a broken tree. - Never invent config keys, function names, or file paths. Every new thing copies an existing pattern named in the task. If you can't find the pattern, mark the task BLOCKED and skip it.
- Tools/acts: never add a tool without
"destructive": trueunless it is provably read-only (see Task 2). A destructive act that runs from voice without a confirm gate is the worst possible bug. When unsure → destructive. - Do NOT attempt the "DEFERRED — needs human" section at the bottom. Those need hardware or protocol decisions. Touching them unsupervised will waste the night. They are listed only so you don't rediscover them.
- Prefer additive changes. Do not refactor existing packages. Do not touch
cmd/mavweb/, encryption, or the store schema unless a task says to.
Work top-to-bottom. Tasks are ordered by value-per-risk: safest and most self-contained first. If you run out of night, the earlier tasks are the ones that matter.
Key facts about the codebase (so you don't have to rediscover them)
- Router cascade:
internal/router/. Intents are the constants inintent.go(act, reminder, fact, note, query, system). Adding an intent = add a const there + seed examples + a handler case. - Intent seeds:
models/seeds/<intent>.txt, one example per line,#comments allowed. Loaded byseedClassifierincmd/mavend/voice.go. To teach the classifier a new phrase, add a line to the right seed file — no code change needed. - Voice intent dispatch:
cmd/mavend/voice.go, the bigswitch dec.Intent(searchcase router.IntentQuery:~line 412). Each intent returns a Russian reply string. This is where a new intent's behaviour hangs. - Tools/acts: enabled allowlist lives in config
voice.tools(seeinternal/config/config.goToolConfig). Executor:internal/tool/tool.go. Args are argv, never shell. Destructive tools returnErrNeedsConfirm. - Config:
internal/config/config.go. Seed/prod config:deploy/mavend.json. - Store (facts, notes, reminders, tools):
internal/store/. CalDAV events are written as facts withsource=caldavplus acalendar_busykey (per the poller inmavpoll/mavcaldav). - Embedder:
voice.embedderconfig → ONNX; nil →router.NewHashEmbedderfloor. Wiring is incmd/mavend/voice.go~line 144. - Language is Russian. Maven refers to herself in the feminine. All user-facing reply strings are RU. Copy tone from existing replies.
Phase 0 — Setup (do this once, first)
git checkout master && git pull(if remote), thengit checkout -b overnight-jul6- Run the full gate (
go build ./... && go vet ./... && go test ./...) on a clean tree to confirm a green baseline before you change anything. If baseline is red, STOP and record it here — do not build on a broken tree.
Task 1 — Embedder config validation + docs (safest, do first)
Goal: make embedder misconfiguration fail loudly instead of silently falling back to the Hash floor.
Files: internal/config/config.go (Validate path), its *_test.go,
deploy/mavend.json, and a short note in START.md or PROGRESS.md.
Do:
- Find where
VoiceConfig/EmbedderConfigis validated (look for aValidate()method or the load path inconfig.go). Add a check: ifEmbedderis non-nil, then all three ofModelPath,TokenizerPath,LibPathmust be non-empty — a partially-filled embedder block is a config error (return fmt.Errorf(...)). IfEmbedderis nil, that's fine (Hash floor) — no error. - In
cmd/mavend/voice.goaround the embedder wiring (~line 144–159), make the "falling back to HashEmbedder" path an explicitlog.Printf("voice: embedder not configured, using HashEmbedder floor")if it isn't already. - Add a test to
config_test.gocovering: all-three-set → ok; one-missing → error; nil → ok. - Document the
voice.embedderblock (all three paths, and "omit the block to use the floor") inSTART.mdnear other config docs.
Done when: new test passes, gate green, docs updated. One commit.
Task 2 — Seed the tool allowlist with safe homelab acts
Goal: give the voice act path a useful, SAFE starter allowlist.
Files: deploy/mavend.json (voice.tools), and models/seeds/act.txt.
Do:
- Add tools to
voice.toolsindeploy/mavend.json. Each:name,cmd(argv prefix),scope,destructive. Classify carefully:- Read-only (destructive: false) — safe to fire from voice:
systemctl status,docker ps,uptime,df,free, journal reads (journalctl -n 50 -u <unit>— note the unit comes as an arg). - Destructive: true — must confirm:
systemctl restart,systemctl stop,docker restart,reboot,docker stop. - When unsure →
destructive: true.
- Read-only (destructive: false) — safe to fire from voice:
- Add matching spoken RU phrasings to
models/seeds/act.txt(e.g. «покажи статус nginx», «перезапусти nginx», «сколько места на диске») so the classifier routes them toact. One per line. - There is no Go change here if the executor already reads
voice.tools. Verify by reading the wiring — if tools are loaded from config into the store allowlist at boot, you're done. If not, mark BLOCKED (don't build new wiring).
Done when: go test ./... still green (config parses), the JSON is valid
(go run the daemon far enough to parse, or a small config-load test). One commit.
Guardrail: double-check no restart/stop/reboot/rm/kill entry has
destructive: false. This is the single most important check of the night.
Task 3 — Calendar event querying ("что у меня завтра?")
Goal: answer calendar questions from CalDAV facts already in the store.
Files: internal/router/ (slots + a query sub-path, or reuse IntentQuery
with a calendar slot), cmd/mavend/voice.go (handler), models/seeds/query.txt,
and tests.
Approach (keep it simple — don't add a new intent if you can avoid it):
- The data is already there: CalDAV events are facts with
source=caldav. Find the store method that reads facts by source/date (grepcaldavininternal/store/). If none scopes by date, add a small read helperCalendarEvents(ctx, from, to time.Time)next to the existing facts queries — copy the style of an existingstore/facts.goquery, with a test. - Add date-scope parsing: «сегодня» → today, «завтра» → tomorrow. Put this in a
small helper in
internal/router/slots.go(copy the RU parsing style inslots_ru_test.go). Test it directly. - In the
IntentQueryhandler invoice.go, detect a calendar question (the utterance mentions планы/календарь/завтра/сегодня + no note match, OR a dedicated keyword check before the notes RAG lookup). Read events for the scoped day, format an RU reply: empty → «на сегодня ничего нет», one/many → list them. Keep formatting in a tested pure helper. - Seed
models/seeds/query.txtwith the example phrasings.
Done when: helper tests + a handler-level test pass, gate green. One commit (or two: store helper, then handler — that's fine, keep them separate).
If store scoping turns out hard: ship just the date parser + formatter as pure tested helpers and wire them to a naive "read all caldav facts, filter in Go" — personal scale, correctness over efficiency. Do not add schema.
Task 4 — General-knowledge routing to the phraser
Goal: route open factual questions to the phraser with an anti-hallucination system prompt and a fallback.
Files: cmd/mavend/voice.go (query handler), phraser call site (grep
phraser / Phrase in voice.go and internal/phraser/), models/seeds/query.txt,
tests.
Do:
- In the
IntentQueryhandler, after the notes-RAG lookup fails to clearqueryMinScore(currently returns «у меня нет заметок…»), instead of giving up, hand the question to the phraser with a system prompt like: «Ответь кратко из своих знаний. Если не знаешь — скажи "не знаю". Не выдумывай.» (feminine self-reference). - Fallback gate: if the phraser returns empty, errors, or the phraser is the Stub (not configured), return «не знаю» / the existing no-answer reply. Never fabricate.
- Keep the prompt construction in a small pure function so you can unit-test it (assert the system prompt text + that empty phraser output → fallback).
Done when: prompt-construction test + fallback test pass, gate green. One commit.
Risk note: the phraser is a small model and will hallucinate. The fallback is the point of this task — test it hard. Do not remove the notes-RAG path; this is a fallback after it.
Task 5 — Weather module skeleton (pure, no network at night)
Goal: a pluggable weather provider interface + an unconfigured stub. No live API calls.
Files: new internal/weather/ package, internal/config/config.go
(a WeatherConfig block, copy PhraserConfig shape), tests.
Do:
internal/weather/weather.go: definetype Provider interface { CurrentWeather(ctx, location string) (Weather, error) }and aWeatherstruct (temp, condition, location). Add aStubProviderthat returns a sentinelErrNotConfigured(or a "погода не настроена" message).- Config: add
Weather *WeatherConfigtoVoiceConfig(fields:Provider,APIKey,DefaultLocation— all omitempty). No API key in the repo. - Optionally add an Open-Meteo provider struct that is not called at night
(no key needed) — but if you write it, do NOT make a network call in tests;
test against a mocked HTTP round-tripper only. If that's too fiddly, ship just
the interface + stub and leave a
// TODO: open-meteo provider— that's fine. - Add a real Open-Meteo provider (keyless — no API key needed). Endpoint:
https://api.open-meteo.com/v1/forecast?latitude=..&longitude=..¤t_weather=true. Geocode viahttps://geocoding-api.open-meteo.com/v1/search?name=<location>. Keep the*http.Clientinjectable (a struct field) so tests use a mocked round-tripper — no real network call in any test. Config selects provider byvoice.weather.provider("open-meteo" | "" → stub). - Wire it into voice.go. In the
IntentQueryhandler, detect a weather question (keywords погода/градус/температура, or aquery_weathersub-path) → call the configured provider withDefaultLocationor a parsed location → format an RU reply. Unconfigured → the stub's «погода не настроена» message. Seedmodels/seeds/query.txtwith «какая погода», «какая погода в москве». Bound the provider call with a context timeout (~5s) so a slow API can't hang the voice turn.
Done when: stub + mocked-Open-Meteo tests pass (round-trip against a fake transport, unconfigured → stub message, location parsing), gate green. Split into two commits if helpful: provider+interface, then voice wiring.
Task 6 — Dialogue state scaffold (pure data structures)
Goal: a session/context data layer for future multi-turn. No LLM, no wiring into the live path unless trivial and tested.
Files: new internal/dialogue/ package + tests only.
Do:
internal/dialogue/session.go: aSessionholding last-turn intent + slots, a timestamp, and a TTL (default ~2min, configurable via a field). ASessionStore(in-memory map keyed by session id) withGet,Put, and TTL-based expiry.- A pure
InheritSlots(prev, cur Slots) Slotshelper: carry forward slots the current turn is missing (e.g. previous had a location, current didn't → use previous). Copy theSlotsshape frominternal/router/intent.go. - Tests: context carry-over, slot inheritance, session expiry, missing prior session. This is the whole task — it's a tested library, not a feature.
Done when: tests pass, gate green. Then (only if the library is solid and
gate is green) wire a minimal read seam into voice.go: on a follow-up-shaped
utterance, look up the prior session's slots and fill the current turn's missing
slots via InheritSlots before routing. Keep the session store's lifetime owned
by the handler struct. If wiring gets fiddly or risks the live path, ship the
tested library and mark the wiring BLOCKED — the library is the required part.
Separate commits: library, then wiring.
Task 7 — Long-term memory vector-store interface (pure)
Goal: an interface + in-memory implementation for a future vector backend.
Files: new internal/memory/store.go + tests only.
Do:
type Store interface { Insert(ctx, id string, vec []float32, meta map[string]string) error; Search(ctx, vec []float32, topK int) ([]Result, error) }.Result= id, score, meta.- An
InMemoryStoreimplementing it with cosine similarity (copy thecosinefunction idea frominternal/router/classifier.go— you may factor a shared helper, but simplest is to reimplement locally; don't refactor the router). - Tests: insert→search round-trip, cosine ordering (nearest first), topK truncation, metadata filtering if you add it. In-memory only.
Done when: tests pass, gate green (library commit). Then wire the embedding
pipeline: in the IntentNote handler in voice.go, after WriteNote, also
Insert the note's embedding + metadata (id, source, ts) into the memory Store.
Use the same embedder the classifier uses (already in scope as h.embedder).
Make the memory Store a field on the handler, defaulting to InMemoryStore so
nothing external is required. Wrap the Insert in its own error branch — a memory
Insert failure must not fail the note write (log and continue). Separate
commit for the wiring.
Phase Final — Verification pass (always do this last)
gofmt -l .prints nothinggo build ./...succeedsgo vet ./...cleango test ./...greendocker compose buildsucceeds (all daemons compile) — if docker is unavailable in the environment, note it and rely ongo build ./....git log --oneline master..HEAD— confirm one commit per completed task, each message names its task, no "wip"/debug commits.- Grep for accidents:
grep -rn "destructive.*false" deploy/mavend.jsonand eyeball every hit;grep -rniE "TODO|FIXME|panic\(|fmt.Println" cmd internal— no stray debug prints, no new panics in live paths. - Update the Status column of each task in this file (Done / BLOCKED:reason / Skipped) so the human can see what happened at a glance.
Status board (fill this in as you go)
| # | Task | Commit | Status |
|---|---|---|---|
| 1 | Embedder config validation + docs | b778f0b |
Done |
| 2 | Seed safe tool allowlist | 3b8fb69 |
Done |
| 3 | Calendar querying | cf066bd |
Done |
| 4 | General-knowledge phraser routing | 428af3f |
Done |
| 5 | Weather skeleton (pure) | e030466 |
Done |
| 6 | Dialogue scaffold (pure) | 79eb43e |
Done |
| 7 | Memory vector interface (pure) | 880715f |
Done |
| F | Final verification pass | d52f60c |
Done — all gates green |
DEFERRED — needs a human, DO NOT ATTEMPT unsupervised
These were in the original plan. They require hardware or protocol decisions and will burn the night if attempted blind. Left here only so you don't rediscover them and think they were forgotten.
- Always-on listening / wake word (
internal/wake/,cmd/mavmic/): needs a hardware decision (USB mic vs Pi vs smart speaker) and a Porcupine license/key. Human input required. - Streaming STT over WebSocket + barge-in (
internal/voicereceive path): changes the wire protocol (PROTOCOL.md) and the STT worker contract. Too invasive to do safely unsupervised; risks breaking the working full-clip path. - Streaming TTS: blocked on Piper. Out of scope.
If you finish Tasks 1–7 with time to spare, do NOT start these. Instead: improve test coverage on what you built, expand the seed files, and improve docs.