Create a minimal SVG icon (512x512, dark rounded square with blue 'M')
and update manifest.json to reference it with purpose 'any'.
This fixes the blank tile on mobile add-to-home-screen and PWA install
prompts.
Simple shell script for backing up, restoring, and verifying the
encrypted SQLite database (AES-256-GCM with MVNC1\0 magic header).
Commands:
backup — cp + magic-verify to MAVEN_BACKUP_DIR
restore — cp back with confirmation prompt
verify — check magic header via od (no xxd/jq dependency)
list — show all backups with size and validity
Portable: uses only POSIX sh, od, grep, stat. Config path resolved
from mavend.json via grep or env var overrides.
Add cron expression support for recurring reminders using robfig/cron/v3.
Changes:
- Migration #2: ALTER TABLE reminders ADD COLUMN cron TEXT + next_fire_ts INTEGER
- Reminder struct: add Cron and NextFireTs fields
- scanReminder helper extracts full row including nullable cron
- CreateReminder: accept optional cron param, store next_fire_ts = fire_ts
- DueReminders: query on next_fire_ts instead of fire_ts
- RescheduleReminder: new method — parse cron, compute next fire, update
next_fire_ts or mark fired if no more valid times
- Dispatcher: call RescheduleReminder for cron reminders, MarkReminder for
one-shots (preserving existing behavior for ID=0 digest skip)
- ReminderCompleter interface: add RescheduleReminder method
- storeAPI adapter: forward RescheduleReminder
- All callers updated: CreateReminder signature includes cron param
- Tests: TestRecurringReminder (store), TestDispatchRecurringReminderReschedules
- Existing tests updated for new signature
Add a 'scope' TEXT column (default 'homelab') to the tools table so tools
can be namespaced by scope (e.g. "homelab:restart", "datacenter:reboot").
Backward-compat: bare name defaults to "homelab" scope.
Changes:
- Migration #1: ALTER TABLE tools ADD COLUMN scope
- store.Tool: add Scope field, update all SQL and scanTool()
- ipc.Tool DTO and request types: add Scope field
- CoreAPI interface: pass scope in ProposeTool/EnableTool
- storeAPI adapters: forward scope
- cmd/mavend/voice: pass scope (empty → homelab)
- cmd/mavweb/tools: show scope column in UI tables, hidden fields
- All tests updated for scope field
- Migration test made dynamic (startVer = len(migrations))
Covers:
- StubHandler.Synthesize: PCM16kMono format, 6400-byte output,
different text → different waveform, empty text → audio
- resample22050To16000: empty input, approximate output length
- defaultSocket: XDG_RUNTIME_DIR resolution
- Integration: full synthesize round-trip via Unix socket
recordingAPI did not implement the new CoreAPI.RevertFact method
added in a02e10f, causing a build failure in auth tests.
Also adds coverage.out to .gitignore.
Tests cover all Send() code paths:
- Nil TTS synthesizer returns tts-not-wired error
- Nil sessions registry returns sessions-not-wired error
- No live session returns delivery.ErrVoiceNoSession (dispatcher reroutes)
- With session: pushes audio_nudge frame with correct kind, rule_name,
text, PCM16kMono format, non-empty audio bytes
- Empty Body falls back to Summary text
- TTS synthesize error propagates with 'synthesize:' prefix
- Invalid audio format rejects with 'refuse to ship' error
Uses net.Pipe() for real voice.Sessions integration, tts.Stub for
deterministic synthesis, and fake synthesizers for error paths.
- New store.VoidLatestFact() method finds latest non-voided fact for
a key and writes a void-marker row pointing at it (transactional).
- New IPC method MethodRevertFact with CoreAPI.RevertFact interface,
storeAPI adapter, server dispatch, and client proxy.
- New HTTP endpoint POST /api/revert?key=<key> in mavweb.
- History page adds a 'revert' button per non-voided fact row with
JS confirmation and optimistic UI (marks row voided on success).
- All existing store, IPC, and mavweb tests pass.
When the daemon starts after being offline, multiple due reminders
would fire simultaneously as separate notifications. Now a single
digest reminder is dispatched instead, summarizing all pending items.
- New collapseReminders() helper in gather.go: if 2+ reminders are
due, marks originals as fired and returns one synthetic reminder
(ID=0) with a combined JSON payload.
- Dispatcher skips MarkReminder for ID=0 (synthetic digest).
- All loop and delivery tests pass.
- New /history route displays recent facts in a dedicated page with
voided-row styling (line-through + opacity + badge).
- Navigation link added to /dash page header.
- Handler calls core.RecentFacts(ctx, 200) and renders historyTmpl.
- New credentialStore type in credentials.go loads/saves
map[id]localCred to a JSON file. Thread-safe with sync.RWMutex,
writes to disk on every mutation.
- PasskeyHandle replaces sync.RWMutex+map with *credentialStore.
Inline save/lookip/update closures delegate to store methods.
- newPasskeyHandle now takes a storePath parameter and returns an
error; callers updated.
- New -passkey-file flag (default ./passkeys.json) configures the
credential store path in main.go.
- Tests use os.CreateTemp in t.TempDir() so each test gets an
isolated, auto-cleaned store file.
- test target now runs with -race and emits coverage.out for coverage
reporting.
- New build-caldav target builds cmd/mavcaldav without CGO (pure Go).
- build-caldav added to PHONY and build dependency chain.
- mavcaldav added to clean target.
The cold-start crash-loop wasn't mavweb-specific — mavpoll and mavcaldav also
ipc.Dial + exit on failure, so they crash-looped until core booted too. Moved
the retry into ipc.DialWait (capped backoff, bounded) and switched mavweb,
mavpoll, mavcaldav to it. mavweb's local dialCoreWithRetry is gone.
Test: server appears after DialWait starts → it waits and connects.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Timezone: the container ran in UTC, so mavend answered clock/date queries
(voice.go replySystem) and evaluated quiet-hours (gather.go) in UTC. Fixed at
the root — process TZ — rather than per-call: TZ=Europe/Samara in compose +
tzdata in the image (debian-slim strips it, without which Go ignores TZ and
stays UTC). One knob fixes replies and quiet-hours for every daemon; change the
zone in compose.
Overflow: the dash "ago" helper ran time.Since on a zero timestamp (no presence
yet / fresh db), saturating to ~292y and rendering "2562047h47m…". Guard zero →
"never".
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
mavweb log.Fatal'd if mavend's socket wasn't up yet, so under compose it
crash-looped (relying on restart:unless-stopped) until core finished booting
its models. depends_on only orders container start, not socket readiness.
dialCoreWithRetry polls with capped backoff up to 60s; still fatal past the
deadline. Mid-life core restarts remain covered by ipc.Client's redial-on-drop.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Temporary reference while testing utterances: a collapsible <details> panel
(native, no JS) listing the six router intents (act/reminder/fact/note/query/
system) with real example phrasings pulled from models/seeds, RU-first since
the voice lang is ru.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Whisper hallucinates subtitle-credit boilerplate ("Редактор субтитров …") on
silence/room-noise, which then got stored as tap:voice facts. Gate before the
model: drop clips shorter than -min-ms (default 300) or below -silence-rms
(default 0.01 normalized RMS). Both are flags — the mic floor is hardware
specific. Returns empty transcript (same as whisper's no-segments path), so
nothing downstream changes.
gateReason is pure and unit-tested (silence/short/quiet → dropped, loud+long →
passes). ponytail: energy gate, not a real VAD; upgrade to WebRTC VAD or
whisper no_speech_prob if too blunt.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
build: . lived only on mavend, so `docker compose build mavweb` (or any other
module) silently built nothing and redeployed a stale mavenai:latest — a fixed
binary looked deployed but wasn't. Moved build into the shared anchor; same
image name means it's still built once, but naming any service now rebuilds.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
mavweb (and every ipc.Client) held one net.Conn from Dial and reused it for the
life of the process. When mavend restarted, the socket got a new inode, the
cached conn went dead, and every call failed forever with "broken pipe" — the
dash and page-heartbeat 502'd until mavweb was manually restarted.
Fix in the one place all 25 methods route through (call): on a lost connection
— write failure OR read EOF, since a peer restart can surface on either phase
depending on socket-buffer timing — drop the conn, re-dial the remembered path,
and retry once. Safe for the case that happens (core restarted, request never
processed); the rare committed-then-died window can double-apply a write, but
the store is append-only so a duplicate is a superseding row, not corruption.
ponytail: retry-once, not request-ids — revisit if double-apply ever bites.
Test reproduces the exact incident: server restart on the same socket path, and
asserts the next call transparently reconnects.
Note (not fixed here): Server.Close waits on its handler goroutines, which park
reading live client conns — so a graceful core shutdown with a client attached
blocks until the client disconnects. Minor; surfaces as a slow SIGTERM.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
"voice unavailable" in the web UI: mavweb dials mavend:9100, but the container
mavend.json had no voice block, so mavend never bound 9100 (worked pre-docker
because the host's ~/.config/maven/mavend.json had one). Ported that block:
enabled, bind 0.0.0.0:9100 (not 127.0.0.1 — mavweb is a separate container),
lang ru, stt/tts worker sockets, onnx embedder.
Enabling the embedder surfaced a second bug: the router needs onnxruntime 1.26,
but deps/lib only carries dangling symlinks to it (absolute host paths, not in
the image), so the only libonnxruntime present was piper's 1.14 (copied in) →
"ORT API base: 2", crash loop. Fixed the Dockerfile to ship the real 1.26 .so
and stop copying piper's .so into the shared lib dir (piper finds its own 1.14
via $ORIGIN + exact soname, so TTS is unaffected).
Verified: mavend "onnx embedder loaded (384 dim)", "voice listening on :9100",
stack stable.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Mapping /dev/dri alone wasn't enough — the container runs as unprivileged
uid 10001, and renderD128 is root:render (mode crw-rw----). Without membership
in the host render gid, Vulkan enumerates zero devices and whisper silently
falls back to CPU. Added group_add 993 (host 'render'); whisper now loads on
RADV RENOIR (AMD Radeon).
Three fixes found bringing the stack up on the host daemon:
- base was golang:1.23-bookworm: bookworm's glibc 2.36 / GLIBCXX 3.4.30 is too
old to link the prebuilt deps/lib/*.so (built on Arch against glibc 2.38 /
GLIBCXX 3.4.32). Moved build+runtime to trixie (glibc 2.40). golang trixie
images start at 1.24, which builds the go 1.23 module fine.
- builder now installs libvulkan-dev — libggml-vulkan.so needs libvulkan.so.1
at link time.
- image renamed maven:latest -> mavenai:latest with pull_policy:never. "maven"
is Apache Maven on Docker Hub; compose was silently pulling it, so every
container ran mvn-entrypoint.sh and exited 127.
Verified on the host: all six build, five-service stack stays up, mavend opens
the encrypted db, mavweb GET :9201 -> 200.
Compose stack replacing start-maven.sh's bare `&`-backgrounded processes.
Single multi-stage image builds all six daemons (CGO + prebuilt native libs
from deps/); compose runs one container each with a different command. Only
mavend mounts the encryption key (env_file, gitignored) and the db volume; the
modules mount just the shared unix-socket dir and read-only models — so the
"key-free modules" boundary is OS-enforced (separate namespaces), not just a
code convention. IPC stays unix-domain over a shared volume: zero code change,
paths move to /run/maven. Encrypted db at rest on a named volume, decrypted
working copy in tmpfs (RAM) per the at-rest encryption landed earlier.
Validated: `docker compose config` clean, mavend.json parses, all daemon flags
confirmed. NOT build-tested (no docker/GPU in authoring env) — deploy/README.md
lists the host-dependent tweak points (GPU passthrough, onnxruntime path,
cross-container voice bind, netdata host).
Chosen Docker over interim systemd units per the "dockerize soon" call — no
throwaway supervisor built.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Recovery already works — DueReminders is (pending AND fire_ts<=now) with no
lower bound and the gatherer calls it every tick, so overdue reminders fire on
the first boot tick. Reclassified to doable-tier "stale-reminder burst collapse"
(cosmetic: avoid a boot-time spam of stale reminders).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- handlers_test.go: first tests for cmd/mavweb (feature-ranking #2). Covers
the /tools enable/disable surface (arg parsing, error mapping, html escaping)
and the webauthn handler contracts (method guards, malformed input). 14 cases.
Verified the enable path is genuinely gated: an un-asserted call fails at the
mavend IPC boundary (Requirement(EnableTool)=AuthStepUp), so mavweb stays a
trust-nothing pass-through and core mediates.
- policy.go: DisableTool now also requires AuthStepUp. It mutates the same tool
allowlist as EnableTool and is a lever to silence a security-relevant tool;
gating allowlist mutation uniformly beats a split rule. ProposeTool stays
maven-callable (no passkey). Corrects the stale api.go comment that claimed
all three gated.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two spine infra items (feature-ranking #1, part of the migration prereq):
- migrations.go: PRAGMA user_version runner, empty (no-op) migration slice,
one tx per step, fail-closed. Mechanism in place before any real schema
change needs it.
- crypt.go: file-level at-rest encryption. On-disk file is always AES-256-GCM
ciphertext; decrypted to a tmpfs working copy modernc sqlite operates on;
re-encrypted atomically on Close, plaintext wiped, key zeroed. Pure stdlib,
CGO stays off. Fails closed on wrong key/tamper, never falls back to
plaintext. Key is a 32-byte seam (config db_key_b64/db_key_env today; the
passkey-derived L3 cold-start key plugs into the same seam later).
Chosen over cgo SQLCipher (would force libsqlcipher + CGO across the project)
and over the ncruces page-level VFS (swaps the driver project-wide); noted as
the upgrade path in a ponytail: comment. Threat model is disk-at-rest only.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The doc was from the initial commit and predated all open-item work. Update
the works-end-to-end list (protocol doc, away-fallthrough, mavcaldav,
quiet-hours schedule, tools enable/disable, note RAG, passkey step-up), rework
the not-built-yet ranking (at-rest crypto, mavweb/mavcaldav tests, systemd),
and add the rationale for deferring multi-user.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Completes the three in-flight open items and fixes the away-fallthrough bug.
Item 7 — passkey step-up (WebAuthn):
- internal/webauthn: ES256/P-256 register + assert with real ecdsa signature
verification, minimal CBOR/COSE decode, PasskeySession (L2→L3 on assert,
decays after TTL). Drop the RS256 offer we can't verify (register-ok/
assert-fail trap). Verify rpIdHash + UP/UV flags in FinishAssertion — UV is
the step-up gesture. Round-trip test with negative cases (tampered sig,
missing UV, wrong origin).
- cmd/mavweb: /auth/passkey enroll+assert page (the only surface that can do
a WebAuthn gesture) + the four begin/finish endpoints. Without this the
daemon's PasskeySession swap leaves /tools enable permanently blocked.
- daemon wires PasskeySession as the auth Session + srv.StepUp; policy gates
MethodAssertStepUp at AuthRead.
Item 5 — tools page: DisableTool through store/ipc/client/wire; /tools grows a
disable action and a link to the passkey page. Lifecycle test.
Item 6 — note RAG: PhraseQuery on the phraser (LLM-composed answer over top-k
notes, raw-notes fallback); IntentQuery routes through it. Stub returns a
deterministic summary.
Item 2 — away-fallthrough: on ErrVoiceNoSession the dispatcher now reroutes
through the AWAY table (sev3→ntfy, sev4→telegram-repeat-til-ack, sev≤2→drop)
instead of silently dropping / mis-routing to the present-list remainder.
Covers DispatchNudge + DispatchReminder. 4 tests.
Also: re-add ProposeTool to CoreAPI (dropped in a comment rewrite), fix
missing imports + a duplicate block left mid-edit, drop dead AssertStepUpFunc,
gitignore /mavcaldav.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds QuietHours config section (start/end as HH:MM local time). The loop's
gatherer checks the window each tick: if now falls within [start, end), the
State.QuietHours flag is set to true regardless of the config fact (which
the voice toggle writes independently). Both sources activate quiet —
schedule AND toggle.
Handles midnight-crossing windows (23:00-08:00). The gate already reads
State.QuietHours for care nudge suppression — no gate change needed.
Polls Radicale for today's events, writes calendar_busy and calendar_event
facts through CoreAPI. Only writes on value change (same append-only
discipline as mavpoll).
Usage: mavcaldav -socket <core> -url <radicale> -user <u> -pass <p>
Flags: -interval (default 5m), -timeout (default 10s).
Fires immediately on start, then on interval.
iCal parser supports UTC and local DTSTART/DTEND, skips all-day events.
PROTOCOL.md: generated from internal/voice/wire.go — documents the
voice wire format (TCP, length-prefixed JSON, methods, pushes, errors)
for multi-client implementors. Must stay in sync with wire.go.
dispatcher: when voice sink returns ErrVoiceNoSession, skip voice and
continue to remaining channels instead of aborting. sev4-present
already has ntfy in the routing table (continues naturally). sev1-3
present have only voice — the loop ends with no dispatches, which
matches the spec (care/ops-soft drop on no-voice).
voicesink: maps voice.ErrNoSession to delivery.ErrVoiceNoSession so
the dispatcher can detect it without importing the voice package.
The dispatcher already routes sev4-away → telegram. The gap is purely
the runtime ErrNoSession fallthrough in voicesink.go. Points agent at
the existing TODO, tells it to converge not duplicate.
Addresses Claude's review:
- Acceptance criteria (done-when) for all 8 open items
- PROTOCOL.md must be generated from wire.go, not freehand
- Resolved CalDAV: Radicale, new cmd/mavcaldav module
- Reordered to core→capabilities→harden priority
- Hard guard: multi-user schema is do-not-touch this phase
- Dropped Nextcloud mention
piper_handler: close stdin/stdout pipes on Start() failure and on
WriteString error instead of leaking fds. Propagate WriteString error.
worker/client: log SetDeadline errors instead of discarding them.
voice/session: pushAudio marshals params inline and returns the marshal
error instead of swallowing it via mustParams (removed).
tool/matcher: log ListTools errors instead of silently returning an
empty allowlist that refuses every act.
config: applyDefaults now sets RouterThreshold and ToolTimeout defaults
so consumers self-contained defaults are belt-and-suspenders.
worker/server.go: dispatch now receives a per-connection context instead of
context.Background(), so handler cancellation propagates on conn close.
voice/server.go: same — per-conn context fed through safeDispatch into
HandlePushToTalk instead of context.Background().
store/reminders.go: propagate LastInsertId error.
store/nudges.go: propagate LastInsertId and RowsAffected errors.
store/tools.go: propagate RowsAffected error.
config/config.go: applyDefaults now respects StateDir when set, using it as
the base for empty DBPath/SocketPath instead of silently ignoring it.
phraser/llmphraser.go: close stderr pipe fd when cmd.Start() fails.
- resolveQuietToggle runs in HandlePushToTalk before the router so
'тихий режим' works regardless of classifier confidence.
- whisper_full() runs in a goroutine with ctx.Done() select so the
handler returns promptly on timeout/shutdown.
- StubReplier.IntentQuery no longer claims query is unimplemented.