Compare commits

...

18 Commits

Author SHA1 Message Date
kami 33e53ee897 Add a replayable full-system simulator on a fake clock (#284)
A scenario is a JSON file under cmd/mavend/testdata/scenarios: a start
instant, a script of canned model answers, and a list of steps at "HH:MM".
Each step does one thing — say, audio, signal, arrive, tick, fault — and
then asserts on what she said, what was sent, which ecosystem services were
called, and what landed in the intake journal.

Between those boundaries the real components run: the real router cascade
(stage0, the LLM router over a scripted completer, the classifier
underneath it), the real store, the real reactive handler, the real tick
loop, and the same intake-decorated ipc.CoreAPI the daemon wires. What is
faked is only what a test cannot have: the model, the microphone, the
speaker, the delivery sink, and the ecosystem HTTP services.

Time is a single fakeClock threaded into every reader — the handler, the
intake publish stamp and tick(ctx, now) — so there is no time.Now() on the
replay path and a scenario is reproducible. TestSimulatorIsDeterministic
enforces that by replaying twice and diffing the transcripts byte for byte;
advanceTo refuses a step that goes backwards.

Two scenarios ship. morning_missed replays #284's own description: he
appears at the desk, a feed item, a mail candidate and a relayed
notification arrive through the morning, two ticks pass, and the assertions
are as much about nothing being sent at him unprompted as about what she
said. evening_degraded picks up the tier-2 pipeline case #288 deferred
here — a golden WAV through the STT seam to a written fact — and then puts
the ecosystem into 503 and checks that the proactive loop stays quiet and
that intake keeps working without it.

This is test-only code. Nothing in the production binaries changed, so the
daemon behaves identically when no scenario is running.

`make simulate` runs them verbose so the transcript is readable; `make
test` runs them with everything else.

Vikunja #284
2026-08-01 06:15:21 +04:00
kami 45b5e16eff Normalize every intake path into one event envelope (#283)
Things arrive at Maven from eight directions — a relayed Android
notification on POST /api/ambient, mail candidates from mavmaild, RSS
items, changed pages from the crawler, zenmoney and wg reads from
mavpoll, CalDAV events, presence probes, meeting transcripts and image
descriptions. Each grew its own shape and its own log line, and nothing
could answer "what came in today, from where".

internal/event is that answer: a flat source-agnostic envelope (Source,
Kind, EntityIDs, Title, Body, Priority, OccurredAt, Payload) plus a
bounded in-memory journal. Both are pure — Publish and Normalize take
`now` as a parameter, so no clock read sits on a path a replay would
drive.

Adopting it did not touch eight callers, because every intake path
already converges on three ipc.CoreAPI methods: WriteFact, WriteNote and
CaptureTask. cmd/mavend/intake.go decorates that ONE interface, so
mavweb, mavcaldav, mavpoll, mavmaild and the in-core feed/crawl/capture/
vision workers publish envelopes without knowing events exist. The lone
exception is cmd/mavend/mail.go, which captures through the store
directly and now publishes explicitly.

Nothing dispatches on an event. It is a report that something arrived,
never an instruction to speak — "a feed item appeared" becoming a
notification is the nag this repo refuses. Digestion may read the
journal later; it will still go through internal/loop's rules and the
severity/presence routing table.

Read surface: ipc.MethodRecentEvents (AuthRead, daemon-cached like
TickTrace — a bare store cannot serve a ring) and a read-only /events
page in mavweb.

Production is unchanged when nobody is watching: a nil *event.Bus makes
Publish a no-op and newIntakeAPI returns the wrapped API untouched, so
config.intake_journal < 0 leaves no decorator on the call path at all.
The default is 512 entries; the "off unless configured" rule is for
capabilities that reach out, and a bounded in-memory log of writes core
already performed reaches nowhere.

Verified: make build, make test (go test -race) both clean. New tests
cover the envelope and ring (internal/event, 95.7%), the decorator's
invariants — a failed write publishes nothing, a deduped capture
publishes nothing, OccurredAt is the fact's Ts and not notice time — and
the /events page including escaping of feed-supplied titles.
2026-08-01 06:05:00 +04:00
kami 4eca20bd94 Derive the cold-start unlock key from the passkey PRF, not the public key (#14)
Cold-start unlock wrapped the database key under the credential *public* key.
A public key is public: mavweb writes it verbatim to passkeys.json, normally in
the same state dir as db_key.wrapped, so anyone holding both files recovered the
database key offline with no authenticator involved. The wrapped blob was a
plaintext key with extra steps.

The secret is now the WebAuthn PRF extension output — 32 bytes the authenticator
computes over a fixed salt and never stores anywhere. The blob gains a version:

  v2:  "MVNKW2\x00" || salt || nonce || AES-256-GCM(key), magic as AAD
  v1:  salt || nonce || AES-256-GCM(key)                  (read-only)

v1 still opens so an existing deployment is not bricked, and reports itself so
the daemon can log a SECURITY line telling him to re-enroll. Nothing writes v1.
The magic is authenticated, so a v2 blob cannot be stripped and re-read as v1.

Four other defects on the same path:

  - The locked-boot store was opened on an IPC goroutine inside UnlockFn and
    never closed. Close is what re-encrypts the tmpfs working copy back over
    the ciphertext, so every write of a cold-started session was lost silently
    on the next boot. daemonLock now owns the store and seals it at shutdown.
  - MethodUnlock was reachable by anything on the box; the socket is same-uid
    and cannot authenticate its caller. It now requires a passkey assertion
    that mavweb verified first.
  - Concurrent unlocks would each open a store and wire a daemon. One at a
    time, and never a second one.
  - The hand-rolled HKDF keyed the expand step with the salt instead of the
    PRK. Replaced with crypto/hkdf.

Key wrapping moves from enrolment to the first assertion, because create() does
not produce a PRF result on most authenticators — only a support flag. An
authenticator without PRF now writes no wrapped file at all rather than one
that looks protected and is not, and the page says so.

Verified: make build, make test. New tests cover the v2 round trip, a wrong
secret, every single-bit tamper, truncation, the v1 downgrade attempt, legacy
v1 reads, non-32-byte and all-zero secrets, the ipc wire field, locked-mode
default-deny, a forged assertion never reaching the unlock path, seal-on-
shutdown after a cold start, and that nothing in the state dir contains the
plaintext key. The PRF round trip against real hardware is a QA step.

Vikunja #14
2026-08-01 05:49:27 +04:00
kami fed33a4e16 Stop mavwaked from hearing itself, and add barge-in (#287)
Playback was `go playAudio(reply)` — fire and forget, nobody holding the
process handle. Two audible consequences fell out of that.

She answered herself. The capture loop kept feeding the VAD while the
speaker was running, so her own reply came back in through the mic,
tripped the VAD, and was shipped to the daemon as a fresh command. There
is no acoustic echo canceller in this pipeline, so the fix is
half-duplex: while she is speaking, the capture side is muted. That part
is unconditional — it repairs a defect, it is not a new capability.

And talking over her did nothing, because there was no handle to cancel.
-barge-in now cuts playback when sustained energy clears a room-tuned
threshold (-barge-in-rms, default 0.12 normalised, over -barge-in-frames
consecutive frames, default 5). It is off by default: without an echo
canceller the only way to tell "he is talking over her" from "the mic is
hearing her" is that he is much louder, and how much louder depends on
where the mic sits.

The frame decision moved out of main.go into session.feed, behind a
player and an utteranceSender interface, so all of it is testable with
no mic, no speaker and no daemon. Nine tests cover the self-hearing
case, the off-by-default case, the consecutive-frame requirement,
speaker-leak-level audio not triggering, capturing the interrupting
utterance after a cut, and failed round-trips not starting playback.

The other seven items on #287 (partial STT, per-segment retry, mic
profiles, noise-floor calibration, short-response-while-speaking) are
untouched and stay on the task.
2026-08-01 05:36:13 +04:00
kami 62cc072f8c Add golden-audio STT tests against real whisper.cpp (#288)
Four committed WAV fixtures go through the real whisper.cpp binding in
cmd/mavsttd, so a wrong model, a wrong language hint, a broken resample
or a regressed silence gate fails `make test` instead of surfacing as
Maven mishearing him.

The fixtures are piper-synthesised, not recorded: scripts/gen-stt-fixtures.sh
drives the vendored piper with the ru_RU-irina voice Maven already speaks
with, so nothing of the owner's voice is committed and every fixture is
reproducible. 360K total for three Russian clips and one English.

Matching is tolerant on purpose. Golden transcripts move with the model,
so each case asserts intent-carrying keywords (prefix match, so Russian
inflection does not fail it) plus a word error rate ceiling, not an exact
string. The matcher is unit-tested on its own and needs no model.

TestGoldenAudioTranscription skips when models/stt/ggml-small.bin is
absent, so `make test` still passes on a box without models.
TestGoldenFixturesAreCanonical runs everywhere and checks the WAVs are
16k mono s16le and would clear mavsttd's own silence gate.
2026-08-01 05:32:10 +04:00
kami 7c7bd8ceeb Ship voice enrolment, and report recognition as blocked (#255)
Maven can now be told who someone is. She cannot yet tell who is speaking,
and this commit is careful to say so rather than pretend otherwise.

What works: profiles are enrolled from several deliberately recorded samples,
listed, and deleted. They live in the existing memory_vectors table under a
"speaker:" id prefix, so there is no migration; what that needed was a wider
interface than memory.Store, hence memory.Catalog with ByPrefix and Delete.
Delete is the load-bearing half — a voiceprint someone asked to be rid of has
to actually go, and a search-only store cannot do that. InMemoryStore.Insert
became an upsert by id to match what the persistent store already did.

What does not work, and why it is not faked: there is no speaker-embedding
model on this box. Sixteen ggufs in /mnt/hdd1/llms, all text; no ECAPA, no
x-vector, no titanet, no wespeaker, no .onnx anywhere under /mnt/hdd1. So
newSpeakerEmbedder returns nil, internal/speaker falls back to
speaker.Disabled, Identify answers ErrDisabled, and the daemon logs which
half is off at startup. The plan's "simple MFCC + GMM" floor is refused in
the package comment: MFCC cosine distance detects channel and loudness as
much as voice, and a biometric that is confidently wrong writes false claims
about named people into his memory. A bad floor is worse than none here.

Refused as well, and the reason is in enroll.go's doc comment: the plan asked
for unknown speakers to be enrolled on first interaction with a TTS "кто
это?". There is no request shape in the protocol that could express that.
Taking a biometric of whoever walks past the microphone does it to guests who
are not party to the exchange, and a synthesised question into a room is not
consent from whoever answers.

Authority: enrolment is AuthStepUp, because it is a deliberate sit-down act
that writes a biometric of a named person and never something done by voice
mid-conversation. Deletion is one rung lower at AuthWrite, deliberately
inverting the usual pattern — getting rid of a biometric must never be the
harder half. Listing is AuthRead and never returns the vectors themselves.

Off unless configured: no speaker block means the three methods answer
ErrUnknownMethod, so a default box has no wire path that takes a voiceprint.

make build and make test pass.

Vikunja #255

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TrVSBKe3RFDF4fGYKWYQnX
2026-08-01 05:23:03 +04:00
kami aa1a26532c Add meeting capture with explicit start and stop (#253)
Maven can record a meeting when she is told to, transcribe it through the
STT she already has, and write a summary note. The audio lives in the blob
store #252 introduced, under the same retention loop.

Nothing here listens. Recorder.Append is the only way audio enters and it
refuses every frame unless someone explicitly started a session, so audio
arriving at an idle core is dropped rather than buffered. The plan document
asked for a keyword trigger ("maven record" heard in the room) and that is
refused: noticing a keyword means listening to the room, which is the one
behaviour this capability must not have.

Off unless configured twice over. No media block means nowhere to keep
audio, no capture block means no recorder, and in either case the four IPC
methods answer ErrUnknownMethod. On an unconfigured box there is no wire
path that begins a recording at all.

A forgotten session ends itself at max_minutes, checked on every append,
and the audio collected before the cap is kept. Stop with discard set is
what "забудь, не записывай" maps to and it leaves nothing behind. The
verbatim transcript is not saved unless save_transcript says so; the
summary is.

Long audio against n_ctx 4096 is handled by map-reduce over 3000-rune
windows rather than by truncation, because a truncated meeting summary
reads as complete and is not. Transcription is windowed at five minutes so
the whisper worker stays responsive to the voice path.

No second STT: internal/capture takes the stt.Transcriber the voice path
already holds. Capture with voice off is refused rather than degraded,
since hours of unreadable audio of other people is worse than no recording.

The three write methods are AuthWrite, not AuthStepUp: step-up needs a
passkey gesture the voice path cannot make, which would leave "запиши
встречу" impossible by voice. capture_status is AuthRead.

make build and make test both pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TrVSBKe3RFDF4fGYKWYQnX
2026-08-01 05:08:08 +04:00
kami d92349ca6e Store and describe images through a shared media intake (#252)
Vision needs a second model this box does not have, so the shipped half is
the part that works without one: an image arrives, is sniffed, is stored
content-addressed, and is prepared for inference. The describing half is
written and tested against a fake server, and refuses any endpoint that is
not on this box.

internal/media is the intake all three senses share — hearing and speaker
recognition store their audio in the same place under the same retention.
Blobs stay out of the sqlite store; only the derived text becomes a note,
and only when the caller asks. Retention is enforced by an hourly prune
loop rather than by a comment.

The plan's RemoteProvider step is refused: no cloud model, inference stays
on the box, and vision.NewLocal validates that at construction.
2026-08-01 04:53:07 +04:00
kami 8d5e357b57 Expose discovered MCP tools through the act allowlist (#251)
Second half of the MCP client: the tools the manager discovers become rows in
the existing act allowlist instead of a parallel capability system.

An MCP tool is encoded in the columns that already exist — cmd
["mcp",<server>,<tool>], scope mcp:<server> — so no migration, and
ProposeTool/EnableTool/DisableTool, tool.Matcher and the confirm turn need no
changes. One branch in Executor.Exec routes such a row to the manager instead
of exec, and "mcp" is never run as a binary.

Discovery only ever PROPOSES. destructive comes from the inverse of the MCP
readOnlyHint, so a tool that does not promise to be read-only inherits the
confirm turn, and enabling stays on /tools behind step-up.

Voice args are positional and MCP args are named, so CallPositional binds only
what it can defend: no required properties runs bare, and a read-only tool with
exactly one required string or number gets the tail. Everything else refuses
with ErrNeedsArgs rather than guessing. The read-only condition was learned
against the live Vikunja server: update_task requires only task_id and takes
the rest as optional, so one guessed argument blanked the fields it did not
mention. A partially-filled write destroys what it omits, so a mutating tool
never receives a guessed argument.

Also: a read-only mcp_servers IPC method and an "MCP servers" card on /tools
showing transport, target and state, with the trust level of a local target
spelled out. There is deliberately no call-a-tool IPC method and no run button,
so mutation keeps exactly one path.

Vikunja #251
2026-08-01 04:36:40 +04:00
kami 95ae900a58 Talk MCP: a client for external tool servers (#251)
docs/plans/06-mcp-support.md asks for the host direction — Maven connects OUT
to MCP servers and consumes what they offer. This is the client half: the
protocol, the transports, the connection manager, the config block. Nothing is
wired into a turn yet, and nothing here exposes Maven's own capabilities to an
outside caller.

internal/mcp:
  - hand-rolled JSON-RPC 2.0 (the wire format is four fields, and the repo
    vendors its deps, so a library would cost more than it saves);
  - two transports: a stdio subprocess on this box, and streamable HTTP, which
    accepts a plain JSON reply or an SSE frame because servers disagree about
    which they send;
  - Client: initialize handshake, tools/list, tools/call, resources/list,
    resources/read. Text content only — everything downstream is a sentence;
  - Manager: lazy dial, per-server failure that never blocks boot or the other
    servers, backoff reconnect, Status for a web surface, graceful Close;
  - the allowlist encoding: a discovered tool becomes the store row
    "vikunja_list_tasks" with cmd ["mcp","vikunja","list_tasks"], scope
    "mcp:vikunja". No new column, no migration, and ProposeTool, EnableTool,
    the act matcher and the confirm turn all keep working untouched.

Constraints held, in code rather than in prose:
  - OFF unless configured, and a server is dark until "enabled": true.
  - A url server goes through internal/webfetch, so the SSRF guard, the size
    cap, the redirect cap and the per-host rate limit apply. Reaching loopback
    needs allow_private on THAT server, and each server gets its own fetcher so
    one loopback exemption cannot become a hole for a public endpoint.
  - readOnlyHint decides destructive: no hint means "assume it mutates", which
    will route the call through the existing confirm turn. Guessing wrong in
    that direction only costs a question.
  - The catalogue stays small on purpose — allow_tools, and max_tools=12 per
    server. The resident model is a 1.7B with a 4096-token context; a tool name
    it half-remembers is a wrong act.
  - Only the tool name and the router's arguments are sent. There is no API
    here through which a note, a fact or the persona block could travel.

webfetch grows Post (JSON-RPC cannot be a GET) and surfaces response headers
for Mcp-Session-Id. It shares Get's guards exactly: a body buys a caller
nothing, a POST to the LAN is refused for the same reason a GET is.

Verified against the real Vikunja MCP server on homesrv
(http://localhost:9100/mcp): handshake, three discovered tools with update_task
correctly NOT read-only, a live list_projects call, a tool excluded by
allow_tools refused, and the same server refused outright once allow_private
was dropped. Tests cover both transports (the stdio one against a real
subprocess), SSE and JSON framing, session echo, reconnect, and the config
validation.
2026-08-01 04:22:52 +04:00
kami be066a4b04 Deploy a new build with verification and automatic rollback (#249)
internal/update applies a new build of Maven to the box she runs on and
undoes it when the new build does not come up. cmd/mavupdate is the only
trigger: a CLI the owner runs on the host.

Apply is health-check the running daemon, snapshot the deployed artifacts,
make build, make test, install, restart, health-check — and restore the
snapshot on any failure. The order is load-bearing:

  - The preflight health check refuses to update a daemon that is already
    not answering. Without a working baseline, a failed update and a box
    that was already broken are indistinguishable, and the rollback has
    nothing to prove itself against.
  - The snapshot is taken BEFORE the build, because make build writes its
    binaries into the working tree and on the docker deployment the tree
    is the install dir — snapshotting afterwards would snapshot the new
    artifacts and leave nothing to roll back to.
  - Verification is make build plus make test, before anything is
    deployed, so a broken tree costs time and nothing else. A failed
    verify also puts the tree's artifacts back, so a later restart by
    hand cannot deploy code that failed its own tests.
  - The rollback depends on nothing that just changed: byte-for-byte
    copies out of the snapshot dir, sha256-verified on the way in, and
    the same restart command. No build, no migration, no cooperation from
    the code being replaced. It also runs on an uncancellable context —
    a rollback interrupted halfway is worse than the failure that caused
    it. When the restore itself fails it says so and names the directory
    to copy back by hand rather than reporting a tidy rollback.

Off unless configured, and the refusals are code, not documentation. The
daemon does not import this package: there is no IPC method, no web route,
no timer and no act that can start an update, so nothing Maven says or
routes reaches it. Nothing fetches code — the new version is whatever the
owner pulled into the tree. The plan's release checker, auto-update
channel and in-process crash-loop supervisor are deliberately absent; a
process cannot reliably notice that it keeps dying, and restart-on-crash
belongs to compose or systemd. The database is never snapshotted or rolled
back; schema compatibility stays store.Migrate's job.

The config is refused at load without a health socket, since an update
that cannot check its own result cannot roll back, and refused when the
snapshot dir is inside the install dir, since a restore must not read from
what the install writes.

Vikunja #249
2026-08-01 04:09:30 +04:00
kami ad074cea31 Swap the resident model without restarting mavend (#250)
Loading a different gguf was a one-line edit to phraser.model_path plus a
restart. It is now an owner-triggered IPC call, off unless configured.

internal/phraser/swap.go holds the safety properties as code:

  - Never two models resident. The old llama-server is killed and reaped
    before the new one is launched. One 1.7B fits the Vega iGPU; a
    blue/green overlap would OOM the box, so it is not offered.
  - Atomic from a turn's point of view. Swap drains the in-flight turns
    (they finish on the old model), then refuses arrivals with ErrSwapping
    until the new server has answered /v1/models. No turn ever sees half a
    swap; refused turns fall back to the classifier cascade.
  - A failed load rolls back. If the new model does not start or does not
    probe, the previous one is reloaded and the call returns RolledBack
    with the error. If the rollback also fails the daemon says so and
    degrades to the classifier rather than pretending to serve.

Holders of the completion client are re-pointed, not rebuilt: llm.Client
guards its base URL and LLMPhraser.OnSwap re-points it, so the router, the
replier, the mail extractor and the memory evaluator follow the new port
without knowing a swap happened.

Reach is deliberately narrow. phraser.swap_models is an exact-match
allowlist of absolute paths a human wrote, rejected at startup otherwise,
so "swap the model" can never mean "load any file on my disk"; the running
model is always swappable back to. MethodSwapModel is AuthStepUp, the same
rung as mutating the tool allowlist, and /models gates POST through the
same stepUpOK the tools page uses. Nothing calls Swap on a timer and no
act, intent or utterance reaches it.

Vikunja #250
2026-08-01 03:59:08 +04:00
kami 2c1b0eede0 Read a web page when he names one, and watch a few on a timer (#259)
The network fallback behind the local sources, off unless configured.

internal/crawl is pure: a stdlib robots.txt parser (group specificity,
wildcards, Crawl-delay, cached per host), HTML-to-plaintext extraction, and a
watcher that notes a watched page only when its text changed. It has no store
access and no net/http; cmd/mavend/crawls.go is the impure half.

Every limit is code and tested: the guarded fetcher from #258 enforces the host
allowlist/denylist, refuses private addresses in the dialer Control hook (so DNS
rebinding and each redirect hop are covered), caps size and redirects, times out,
and spaces requests per host. A robots.txt Disallow is refused with no override.

On demand, reading is a query source placed last in the chain, after his memory,
his notes, and the local Kiwix ZIMs once those are wired: no URL in the
utterance means no fetch, and only the URL ever leaves the box. Scheduled
watches write notes and announce nothing.

The vendored tree has no x/net/html, goquery or temoto/robotstxt, so the parsers
are stdlib. No new dependency.
2026-08-01 03:40:21 +04:00
kami cb3641e7bb Read RSS and Atom feeds, and speak about them only when asked (#258)
internal/rss parses RSS 2.0 and Atom, and polls each configured feed on its own
interval; internal/webfetch is the one door either of them uses to touch the
network. The poller writes items as notes with source "rss:<feed>" and nothing
else: the answer path reads them back when he asks "что нового в лентах?", and
nothing is announced on arrival. A feed that dispatched would be a nag, which is
why the plan's breaking-news rule was left out rather than built.

webfetch is where the limits live, as code rather than a paragraph: http(s)
only, an allowlist (the configured feeds' hosts) and a denylist, a 2 MiB body
cap, a 3-redirect cap, one request per host per second, and a refusal to connect
to any private address — checked in the dialer's Control hook so it holds for
every resolved address and every redirect hop, not just for a literal IP.

Off unless configured: no "feeds" block, no poller, no outbound request. How far
a feed was read is a config fact (rss:latest:<name>), so a restart does not
re-note yesterday's headlines.
2026-08-01 03:27:45 +04:00
kami ee7bec11e3 Add mavmaild, the read-only IMAP poller that feeds mail intake (#246)
The extraction seam landed on the previous branch but nothing fed it. This
adds the daemon that does: every interval it opens one mailbox read-only
(EXAMINE + BODY.PEEK, so reading leaves no \Seen behind), fetches the UIDs
it has not handed over yet, and posts each message to core over
ingest_mail. Core runs the model and writes task candidates; this daemon
writes nothing and cannot create a reminder.

It is a separate daemon because of the credential. mavpoll set the
precedent with the zenmoney token (#125): the module talking to the third
party holds the secret, reads it from a file so it never lands in argv, in
docker-compose.yml or in shell history, and core never sees it. There is
deliberately no -password flag, and a test asserts that.

Off unless configured at both ends: without -password-file the daemon
refuses to start, and if core has no email block the first ingest returns
ErrUnknownMethod, which disables the reader instead of hammering a socket
that will keep refusing. A seen-UID state file (0600, atomic write) keeps a
restart from re-extracting the whole lookback window; correctness does not
depend on it, since capture dedupes on normalised text. Logs are counts and
UIDs — no subject, sender or body.

Verified with an in-process IMAP server and a fake core: bulk mail is
filtered before core is asked, seen UIDs are not re-fetched, a failed
ingest is retried next poll, ErrUnknownMethod stops at the first message,
and state survives a restart. The live half is untested by design — no IMAP
credential exists on this box; setup is written up as QA steps.

Vikunja #246
2026-08-01 03:13:18 +04:00
kami f42d1594ef Turn a mail into task candidates, and into nothing else (#246)
The extraction half. internal/email.Extractor asks the resident Qwen3-1.7B,
under a GBNF grammar, what one message requires of him, and returns at most
three short candidates with an optional date.

Everything it can produce is a row in `tasks` with status "candidate",
written through the intake seam #130 built for exactly this (Source
"email:<mailbox>", Evidence = the subject line). No reminder, no fact, no
note, no calendar event. That bound is the design: a reminder FIRES, so a
1.7B misreading "встреча была в четверг" as a future appointment would wake
him up about it, whereas a wrong candidate is a line he dismisses in one
click. A due date the model read out of the mail is stored on the candidate,
where no scheduler reads it — the review page sorts by it. Relative wording
("до пятницы") is deliberately left in the text rather than resolved to a
date the model would get wrong.

The prompt is written against the two things a small model does here: it
summarises when asked to extract, and it invents an obligation out of a
polite closing line. Hence the demand for a verb phrase, and an explicit
empty array — most mail contains no task, and a model with no way to say
"nothing" says something.

Wiring: core owns extraction because llama-server lives in core's process,
so the reader hands messages over a new ipc.MethodIngestMail. It is a Server
hook (like StepUp/UnlockFn), not a CoreAPI method — not a store operation,
and no CoreAPI implementation should have to carry it. The hook stays nil
without an `email` config block or without a llama-server phraser, so the
method answers ErrUnknownMethod: off unless configured, twice over. There is
no keyword fallback on purpose — "the subject became a task" is a mailbox
rendered as a to-do list, not extraction.

Privacy: junk is refused before the model is called, mail text is never
search input, extraction errors carry byte counts rather than the reply, the
stored evidence is a truncated subject, and the log line names the mailbox
and the UID only.
2026-08-01 03:06:55 +04:00
kami b4646155b4 Read a mailbox read-only, in a client small enough to audit (#246)
internal/email is the reading half of the email reader: a ~200-line IMAP
client (LOGIN, EXAMINE, UID SEARCH SINCE, UID FETCH BODY.PEEK, LOGOUT), a
MIME-to-plaintext converter, and a header-only junk filter.

Two protocol choices are the design, not shortcuts. EXAMINE instead of
SELECT means the session is read-only at the protocol level, so no command
in it can flip a flag or expunge anything by mistake. BODY.PEEK instead of
BODY means reading a message does not mark it \Seen — Maven reads his mail
and leaves no trace of having done so, and the unread state in his own
client stays his.

Hand-rolled rather than go-imap because this is the one path that holds his
mailbox credential and reads his private mail: five commands with no
dependencies is auditable in a sitting. No IDLE and no cleartext/STARTTLS
either — an option to send his password over a plain socket is an option to
get it wrong once.

Junk is decided by headers alone, before any model is involved:
List-Unsubscribe/List-Id, Precedence: bulk, Auto-Submitted, the spam
headers, and Gmail's own category labels. Sender lists and subject keywords
are deliberately absent — they age badly and they would put his contacts in
a config file. A junk verdict only means "do not spend the model on this";
nothing is deleted and no server flag is touched.

Nothing here logs a body, a subject or an address, the junk reason names a
header rather than content, and an undecodable charset degrades to
headers-only instead of feeding the model mojibake. Verified against
recorded .eml fixtures and an in-process fake IMAP server.
2026-08-01 02:59:24 +04:00
kami da647e87d0 Read spending from zenmoney in the poller, answer it from facts (#125)
The trust boundary is zenmoney, not maven — they already hold his bank
sessions. So the poller reads /v8/diff/ and writes totals as
facts(kind=env, source=poll:zenmoney); core reads those back when he asks
and never sees the token.

internal/zenmoney sums transactions per currency over a window, skipping
tombstoned rows and transfers between his own accounts, and refuses to
encode a summary built from zero transactions. That refusal is the whole
design: a failed or empty read writes nothing and leaves the last good
total alone, because a zero recited as fact is worse than silence. No
currency conversion either — a figure he can check against his bank beats
one he cannot.

Off unless configured, and the token is read from a FILE rather than a
flag so it never lands in `ps`, in docker-compose.yml, or in shell
history. Nothing about the money is search input, no tick rule reads the
keys, and the log lines name keys, never figures.

The live-credential half is BLOCKED: there is no zenmoney account or token
here, so everything is verified against a recorded diff fixture.
2026-08-01 02:50:27 +04:00
170 changed files with 24649 additions and 401 deletions
+6
View File
@@ -7,6 +7,8 @@
/mavpoll
/mavcaldav
/mavwaked
/mavmaild
/mavupdate
# Certs (private keys, don't commit)
certs/
@@ -34,6 +36,10 @@ deps
deploy/db_key.env
# Deploy secret (telegram bot token + chat id) — never commit
deploy/telegram.env
# zenmoney API token, read by mavpoll (never in argv, never committed)
deploy/zenmoney.token
# IMAP password, read by mavmaild (never in argv, never committed)
deploy/imap.password
# Temp files
/tmp/
+2 -1
View File
@@ -34,7 +34,7 @@ CGO daemons (`mavend`, `mavsttd`, `mavttsd`, `mavenclient`) need the vendored to
and libs wired through the Makefile — **do not** call `go build` on them bare, use `make`:
```sh
make build # all 8 binaries
make build # all 9 binaries
make build-web # single daemon (pure-Go ones: web/waked/poll/caldav build without CGO)
make test # go test -race across ./internal/... ./cmd/... with CGO env set
```
@@ -62,6 +62,7 @@ Pure-Go packages (`router`, `memory`, `mavweb`, …) run under a plain `go test
| `mavenclient` | Voice loop client (mic → stt → core → tts). |
| `mavpoll` | Telegram long-poll reach. |
| `mavcaldav` | CalDAV calendar sync. |
| `mavmaild` | Mail reader (IMAP, read-only). Holds the IMAP password; core never sees it. |
Daemons are wired socket-to-socket, not linked. `internal/ipc` is the client/server wire
protocol; the config in `deploy/mavend.json` (with `${VAR}` env expansion from gitignored
+2 -1
View File
@@ -51,7 +51,8 @@ RUN go build -o /out/mavend ./cmd/mavend && \
go build -o /out/mavttsd ./cmd/mavttsd && \
go build -o /out/mavweb ./cmd/mavweb && \
go build -o /out/mavpoll ./cmd/mavpoll && \
go build -o /out/mavcaldav ./cmd/mavcaldav
go build -o /out/mavcaldav ./cmd/mavcaldav && \
go build -o /out/mavmaild ./cmd/mavmaild
# llama.cpp Vulkan build — the phraser/router LFM engine (llama-server). Built
# from source (not a prebuilt vendored blob) so the binary's glibc/GLIBCXX match
+31 -3
View File
@@ -16,11 +16,11 @@ PIPER_BIN := $(shell pwd)/deps/piper/piper
PIPER_MODEL := $(shell pwd)/models/tts/ru_RU-irina-medium.onnx
PIPER_ESPEAK := $(shell pwd)/deps/piper/espeak-ng-data
.PHONY: all build build-stt build-tts build-daemon build-client build-waked build-web build-poll build-caldav clean test fmt-check vet run-stt run-tts run-web download-embedder deps-go eval-router eval-recall eval-phrasing eval-models
.PHONY: simulate stt-fixtures test-stt-golden all build build-stt build-tts build-daemon build-client build-waked build-web build-poll build-caldav clean test fmt-check vet run-stt run-tts run-web download-embedder deps-go eval-router eval-recall eval-phrasing eval-models
all: build
build: build-stt build-tts build-daemon build-client build-waked build-web build-poll build-caldav
build: build-stt build-tts build-daemon build-client build-waked build-web build-poll build-caldav build-mail build-update
build-stt:
CGO_CFLAGS="$(CGO_CFLAGS)" CGO_LDFLAGS="$(CGO_LDFLAGS)" LD_LIBRARY_PATH="$(shell pwd)/deps/lib" \
@@ -50,6 +50,15 @@ build-poll:
build-caldav:
$(GO) build $(GOFLAGS) -o mavcaldav ./cmd/mavcaldav/
build-mail:
$(GO) build $(GOFLAGS) -o mavmaild ./cmd/mavmaild/
# mavupdate is an operator CLI, not a daemon: nothing runs it but a human on the
# box. It is built with the rest so a broken update path is caught by `make
# build` rather than the first time it is needed.
build-update:
$(GO) build $(GOFLAGS) -o mavupdate ./cmd/mavupdate/
run-web: build-web
./mavweb -addr :9200 -voice 127.0.0.1:9100
@@ -82,6 +91,14 @@ vet:
CGO_CFLAGS="$(CGO_CFLAGS)" CGO_LDFLAGS="$(CGO_LDFLAGS)" LD_LIBRARY_PATH="$(shell pwd)/deps/lib" \
$(GO) vet ./internal/... ./cmd/...
# simulate — replay every scripted day under cmd/mavend/testdata/scenarios
# through the real router, store, tick loop and intake journal, on a fake clock
# (Vikunja #284). Verbose so the transcript of each scenario lands in the
# terminal. Also runs as part of `make test`; this target is for reading it.
simulate:
CGO_CFLAGS="$(CGO_CFLAGS)" CGO_LDFLAGS="$(CGO_LDFLAGS)" LD_LIBRARY_PATH="$(shell pwd)/deps/lib" \
$(GO) test -v -count=1 -run TestSimulator ./cmd/mavend/
test: fmt-check vet
CGO_CFLAGS="$(CGO_CFLAGS)" CGO_LDFLAGS="$(CGO_LDFLAGS)" LD_LIBRARY_PATH="$(shell pwd)/deps/lib" \
$(GO) test -race -coverprofile=coverage.out ./internal/... ./cmd/...
@@ -130,6 +147,17 @@ eval-models:
MAVEN_LLM_URL="$(MAVEN_LLM_URL)" $(GO) test -v -count=1 -timeout 60m \
-run TestLLMRouterBaseline ./internal/router/eval/
# stt-fixtures — regenerate the golden STT audio in cmd/mavsttd/testdata from
# the piper voices (#288). The committed WAVs are synthesised, never recorded,
# so this is the only way they should ever change. TestGoldenAudioTranscription
# then scores them against ggml-small; it self-skips when the model is absent.
stt-fixtures:
./scripts/gen-stt-fixtures.sh
test-stt-golden:
CGO_CFLAGS="$(CGO_CFLAGS)" CGO_LDFLAGS="$(CGO_LDFLAGS)" LD_LIBRARY_PATH="$(shell pwd)/deps/lib" \
$(GO) test -v -count=1 -run TestGolden ./cmd/mavsttd/
run-stt: build-stt
LD_LIBRARY_PATH="$(shell pwd)/deps/lib" \
./mavsttd -socket /tmp/maven/stt.sock -model $(WHISPER_MODEL)
@@ -185,4 +213,4 @@ download-embedder:
@echo ' sudo cp onnxruntime-linux-x64-1.15.1/lib/libonnxruntime.so* /usr/local/lib/'
clean:
rm -f mavend mavenclient mavsttd mavttsd mavweb mavpoll mavcaldav mavwaked
rm -f mavend mavenclient mavsttd mavttsd mavweb mavpoll mavcaldav mavwaked mavmaild
+7
View File
@@ -5,6 +5,7 @@ import (
"errors"
"log"
"github.com/kami/maven/internal/mcp"
"github.com/kami/maven/internal/router"
"github.com/kami/maven/internal/tool"
)
@@ -52,6 +53,12 @@ func (h *reactiveHandler) actionAct(ctx context.Context, dec router.Decision) st
return "выполнить «" + phrase + "»? скажи «да» или «нет»."
case errors.Is(err, tool.ErrNotEnabled):
return h.proposeGap(ctx, dec)
case errors.Is(err, mcp.ErrNeedsArgs):
// An MCP tool that wants named arguments a spoken verb cannot
// supply. Guessing them would be a wrong act, so she says so
// instead — the tool is still runnable from the authed surface,
// where a human types them.
return "этому инструменту нужны аргументы, которые я из голоса не соберу — я не буду угадывать."
}
log.Printf("voice: tool %s: %v", dec.Slots.Fn, err)
if out != "" {
+78
View File
@@ -0,0 +1,78 @@
package main
import (
"context"
"log"
"github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/router"
"github.com/kami/maven/internal/zenmoney"
)
// Money questions (Vikunja #125).
//
// This is the whole read side: mavpoll holds the zenmoney token and writes
// facts(kind=env, source=poll:zenmoney); core reads them back when he asks.
// Core never sees the token, never calls zenmoney, and has no rule on these
// keys — a total is never a reason for Maven to speak first. Maven is not a
// nag, least of all about his money.
//
// Nothing here can reach the external search capability: the figures are read
// from the store and rendered locally, and his financial data is never search
// input.
// queryMoney — "сколько я потратил сегодня?", "покажи мои траты".
//
// Answers only from the latest fact the poller wrote. Three honest outcomes and
// no fourth: the figure, "the fact is old and here is its date", or "money
// tracking is not connected". It never computes, estimates or rounds a total of
// its own — an invented number about his money is the worst thing this could do.
func (h *reactiveHandler) queryMoney(ctx context.Context, t *queryTurn) (string, bool) {
window, ok := router.ParseMoneyQuery(t.dec.Utterance)
if !ok {
return "", false
}
key, phrase := zenmoney.KeySpentMonth, "в этом месяце"
if window == router.MoneyToday {
key, phrase = zenmoney.KeySpentToday, "сегодня"
}
fact, err := h.api.LatestFactBySource(ctx, key, zenmoney.Source)
if err != nil {
// No fact at all is the normal state when the capability is off. Claim
// the turn anyway: falling through to recall would answer a question
// about money with whatever note happens to be nearest.
if !isNoFactErr(err) {
log.Printf("voice: money fact: %v", err)
}
return "я не отслеживаю траты — не подключено.", true
}
val, err := zenmoney.ParseFactValue(fact.Value)
if err != nil {
log.Printf("voice: money fact: decode: %v", err)
return "не получилось прочитать траты.", true
}
reply := val.FormatRU(phrase)
if reply == "" {
return "по тратам пока нечего сказать.", true
}
// A stale fact is reported as stale rather than spoken as today's number.
if h.now().Sub(fact.Ts) > zenmoney.StaleAfter {
return "данные от " + fact.Ts.Local().Format("02.01") + ": " + reply, true
}
return reply, true
}
// isNoFactErr — ErrNoFact survives the wire wrapped, so unwrap for it.
func isNoFactErr(err error) bool {
for e := err; e != nil; {
if e == ipc.ErrNoFact {
return true
}
u, ok := e.(interface{ Unwrap() error })
if !ok {
return false
}
e = u.Unwrap()
}
return false
}
+139
View File
@@ -0,0 +1,139 @@
package main
import (
"context"
"strings"
"testing"
"time"
"github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/router"
"github.com/kami/maven/internal/zenmoney"
)
// moneyAPI answers only LatestFactBySource; everything else is unimplemented,
// which is the assertion that answering a money question costs no model call
// and reaches no network.
type moneyAPI struct {
ipc.UnimplementedCoreAPI
fact ipc.Fact
err error
gotKey string
gotSrc string
callCnt int
}
func (a *moneyAPI) LatestFactBySource(_ context.Context, key, source string) (ipc.Fact, error) {
a.gotKey, a.gotSrc = key, source
a.callCnt++
return a.fact, a.err
}
func moneyNow() time.Time { return time.Date(2026, 8, 15, 20, 0, 0, 0, time.UTC) }
func moneyFact(ts time.Time, val string) ipc.Fact {
return ipc.Fact{Kind: "env", Key: zenmoney.KeySpentMonth, Value: val, Source: zenmoney.Source, Ts: ts}
}
func TestQueryMoneyAnswersFromTheFact(t *testing.T) {
api := &moneyAPI{fact: moneyFact(moneyNow(), `{"spent":[{"currency":"RUB","amount":1749.5}],"count":3}`)}
h := &reactiveHandler{api: api, now: moneyNow}
reply, ok := h.queryMoney(context.Background(), &queryTurn{
dec: router.Decision{Utterance: "сколько я потратил в этом месяце?"},
})
if !ok {
t.Fatal("the money source must claim a money question")
}
if api.gotKey != zenmoney.KeySpentMonth || api.gotSrc != zenmoney.Source {
t.Errorf("read %q/%q, want the month key from the poller's source", api.gotKey, api.gotSrc)
}
if !strings.Contains(reply, "1749.5") {
t.Errorf("reply = %q, want the exact figure", reply)
}
if !strings.Contains(reply, "в этом месяце") {
t.Errorf("reply = %q, want the window named", reply)
}
}
func TestQueryMoneyPicksTodaysKey(t *testing.T) {
api := &moneyAPI{fact: moneyFact(moneyNow(), `{"spent":[{"currency":"RUB","amount":250}],"count":1}`)}
h := &reactiveHandler{api: api, now: moneyNow}
if _, ok := h.queryMoney(context.Background(), &queryTurn{
dec: router.Decision{Utterance: "сколько я потратил сегодня?"},
}); !ok {
t.Fatal("expected the source to claim it")
}
if api.gotKey != zenmoney.KeySpentToday {
t.Errorf("key = %q, want today's", api.gotKey)
}
}
// The capability is off unless configured, and then there is no fact. She says
// so instead of letting the recall pass answer a money question from a note.
func TestQueryMoneySaysNotConnected(t *testing.T) {
h := &reactiveHandler{api: &moneyAPI{err: ipc.ErrNoFact}, now: moneyNow}
reply, ok := h.queryMoney(context.Background(), &queryTurn{
dec: router.Decision{Utterance: "сколько я потратил?"},
})
if !ok {
t.Fatal("expected the source to claim it")
}
if !strings.Contains(reply, "не подключено") {
t.Errorf("reply = %q, want an honest 'not connected'", reply)
}
// No number of any kind in that answer.
for _, d := range []string{"0", "1", "2", "3", "4", "5", "6", "7", "8", "9"} {
if strings.Contains(reply, d) {
t.Errorf("reply %q contains a digit — nothing was read, so there is no figure", reply)
}
}
}
// A fact older than the staleness bound is dated rather than spoken as if it
// were current: the poller can be down, and last week's total presented as
// today's is a lie by omission.
func TestQueryMoneyDatesAStaleFact(t *testing.T) {
old := moneyNow().Add(-72 * time.Hour)
api := &moneyAPI{fact: moneyFact(old, `{"spent":[{"currency":"RUB","amount":100}],"count":1}`)}
h := &reactiveHandler{api: api, now: moneyNow}
reply, _ := h.queryMoney(context.Background(), &queryTurn{
dec: router.Decision{Utterance: "сколько я потратил?"},
})
if !strings.Contains(reply, "данные от") {
t.Errorf("reply = %q, want the stale fact dated", reply)
}
}
func TestQueryMoneyPassesOtherQuestions(t *testing.T) {
api := &moneyAPI{}
h := &reactiveHandler{api: api, now: moneyNow}
for _, u := range []string{"какая погода?", "я потратил весь день на это", "какие у меня задачи?"} {
if _, ok := h.queryMoney(context.Background(), &queryTurn{dec: router.Decision{Utterance: u}}); ok {
t.Errorf("the money source claimed %q", u)
}
}
if api.callCnt != 0 {
t.Error("a non-money question must not read the money facts")
}
}
// Money must be answered before the recall sources, or a question about
// spending gets answered by the nearest note.
func TestQuerySourcesOrderMoneyBeforeRecall(t *testing.T) {
moneyAt, notesAt := -1, -1
for i, src := range querySources {
switch src.name {
case "money":
moneyAt = i
case "notes":
notesAt = i
}
}
if moneyAt < 0 || notesAt < 0 {
t.Fatalf("sources missing: money=%d notes=%d", moneyAt, notesAt)
}
if moneyAt > notesAt {
t.Errorf("money source at %d, after notes at %d", moneyAt, notesAt)
}
}
+127
View File
@@ -8,10 +8,12 @@ import (
"strings"
"time"
"github.com/kami/maven/internal/crawl"
"github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/memory"
"github.com/kami/maven/internal/morning"
"github.com/kami/maven/internal/router"
"github.com/kami/maven/internal/rss"
"github.com/kami/maven/internal/weather"
)
@@ -62,11 +64,28 @@ var querySources = []querySource{
// matcher requires a task noun or an explicit "что … сделать", so a
// date-bearing question still reaches the calendar.
{"tasks", (*reactiveHandler).queryTasks},
// Before the recall sources too: "сколько я потратил?" is a question about
// the money facts the poller wrote, and the notes pass would otherwise
// answer it from whatever he once said about spending. Its matcher needs a
// money noun plus an actual ask, so "я потратил весь день" is untouched.
{"money", (*reactiveHandler).queryMoney},
// Before the recall sources and before general knowledge: "что нового?" is
// a question about the feeds she reads, and general knowledge would answer
// it by inventing news. Its matcher needs a feed noun plus an ask, so
// "у меня новая лента в инстаграме" is untouched.
{"feeds", (*reactiveHandler).queryFeeds},
{"calendar", (*reactiveHandler).queryCalendar},
{"weather", (*reactiveHandler).queryWeather},
{"embed", (*reactiveHandler).queryEmbed},
{"memory", (*reactiveHandler).queryMemory},
{"notes", (*reactiveHandler).queryNotes},
// LAST before the model answers from memory, and that position is the whole
// design (Vikunja #259): local sources first. The model, his own notes and
// facts, and — once internal/kiwix is wired into this chain — the offline
// ZIMs all get their turn before anything touches the network. This source
// only claims a turn where he named a URL out loud, so it never competes
// with a local answer.
{"web", (*reactiveHandler).queryWeb},
{"general-knowledge", (*reactiveHandler).queryGeneral},
}
@@ -175,6 +194,63 @@ func (h *reactiveHandler) queryHabits(ctx context.Context, t *queryTurn) (string
return profile.FormatOverallRU(), true
}
// feedNoteWindow — how many recent notes are scanned for feed items, and
// feedReadOut — how many headlines she actually reads back. She summarises the
// top of the pile, she does not recite a river.
const (
feedNoteWindow = 200
feedReadOut = 3
)
// queryFeeds — "что нового в лентах?", "что нового по технологиям?"
// (Vikunja #258).
//
// This is the ONLY way a feed item reaches him. The poller writes notes and
// never speaks; asking is the trigger. If that ever changes, the thing that
// changed is "Maven is not a nag", not a detail of this file.
func (h *reactiveHandler) queryFeeds(ctx context.Context, t *queryTurn) (string, bool) {
q, ok := router.ParseFeedQuery(t.dec.Utterance)
if !ok {
return "", false
}
if !h.feedsOn {
// Claim the turn rather than fall through: "не читаю ленты" is true, and
// letting general knowledge answer "что нового?" would be an invented
// news bulletin.
return "я пока не читаю ленты — они не настроены.", true
}
notes, err := h.api.RecentNotes(ctx, feedNoteWindow)
if err != nil {
log.Printf("voice: feeds: recent notes: %v", err)
return "не получилось посмотреть ленты.", true
}
var picked []string
for _, n := range notes {
if !strings.HasPrefix(n.Source, rss.SourcePrefix) {
continue
}
if !router.CategoryMatches(n.Text, q.Category) {
continue
}
// The note carries title, summary and link; she reads the title.
title := n.Text
if i := strings.IndexByte(title, '\n'); i > 0 {
title = title[:i]
}
picked = append(picked, strings.TrimSpace(title))
if len(picked) == feedReadOut {
break
}
}
if len(picked) == 0 {
if q.Category != "" {
return "по этой теме в лентах пока ничего.", true
}
return "в лентах пока ничего нового.", true
}
return "вот что нового: " + strings.Join(picked, "; "), true
}
// queryCalendar — "что у меня сегодня?", "планы на завтра?"
// h.now(), not time.Now(): the handler's clock is the injected one, so this
// source can be tested at a fixed time like the rest.
@@ -303,6 +379,57 @@ func (h *reactiveHandler) queryNotes(ctx context.Context, t *queryTurn) (string,
return reply, true
}
// webPageContextRunes — how much of a fetched page is handed to the phraser.
// Less than the crawler keeps: the rest of the 4096-token window belongs to the
// prompt, the persona block and the reply.
const webPageContextRunes = 1500
// queryWeb — "посмотри https://example.org/x — что там?" (Vikunja #259).
//
// It claims a turn ONLY when he named a URL, which is what keeps a fallback from
// becoming a habit: no URL, no fetch, and the model answers from what is local.
// What leaves the box is the URL and nothing else — no note, no fact, no history
// travels with it.
func (h *reactiveHandler) queryWeb(ctx context.Context, t *queryTurn) (string, bool) {
link, ok := router.FirstURL(t.dec.Utterance)
if !ok {
return "", false
}
if h.crawler == nil {
// Claim rather than fall through: he asked about a specific page, and
// letting the model answer from the URL's spelling alone is how a small
// model invents a page's contents.
return "я не читаю страницы — это не настроено.", true
}
ctxFetch, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
page, err := h.crawler.Page(ctxFetch, link)
if err != nil {
if errors.Is(err, crawl.ErrRobots) {
return "эта страница закрыта для чтения — robots.txt не разрешает.", true
}
log.Printf("voice: web: %v", err)
return "не получилось прочитать страницу.", true
}
if page.Text == "" {
return "страница открылась, но читать там нечего.", true
}
// The page is handed to the phraser the same way a note is: as context for
// the question he actually asked. She answers the question, she does not
// recite the page.
snippet := page.Title + "\n" + crawl.TrimRunes(page.Text, webPageContextRunes)
reply, perr := h.phraser.PhraseQuery(ctx, t.dec.Utterance, []string{snippet})
if perr != nil {
log.Printf("voice: web: phrase: %v", perr)
}
if reply == "" {
// No phraser (or it failed): read back the top of the page rather than
// pretend the fetch did not happen.
return "вот что на странице: " + crawl.TrimRunes(page.Text, 300), true
}
return reply, true
}
// queryGeneral — general knowledge from the phraser, the last source before
// giving up. It always claims: either the model answers or Maven says she
// doesn't know.
+263
View File
@@ -0,0 +1,263 @@
// mavend/capture.go — core's half of the meeting recorder (Vikunja #253,
// docs/plans/08-hearing.md).
//
// The split: a client that has a microphone (mavenclient, or a phone on the PWA)
// is told to start, streams frames over ipc.MethodCaptureAppend, and is told to
// stop. Core keeps the PCM, stores it as a WAV blob under the same media store
// and the same retention as images, transcribes it through the ONE STT Maven has
// (mavsttd's whisper.cpp, reused — not a second engine), and summarises the
// transcript on the resident model in windows that fit n_ctx 4096.
//
// # Off unless configured, twice over
//
// No `media` block ⇒ nowhere to keep audio ⇒ the four capture methods do not
// exist. No `capture` block with enabled ⇒ they still do not exist. On an
// unconfigured box there is no wire path that starts a recording, which is the
// only guarantee worth making about a capability like this one.
//
// # What this file refuses to do
//
// - Nothing listens. There is no VAD hook here, no wake-word branch, no
// "start when you hear a meeting". The plan document's keyword-triggered
// recorder is refused in internal/capture's package comment for the reason
// that applies here too: noticing a keyword requires listening, which is
// the behaviour this capability must not have.
// - No transcript note by default. The summary is written where he will read
// it; the verbatim record of what other people said takes a deliberate
// capture.save_transcript.
// - The transcript is never search input beyond this box, and the audio never
// leaves it at all.
package main
import (
"context"
"errors"
"fmt"
"log"
"time"
"github.com/kami/maven/internal/capture"
"github.com/kami/maven/internal/config"
"github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/llm"
"github.com/kami/maven/internal/phraser"
"github.com/kami/maven/internal/router"
"github.com/kami/maven/internal/store"
)
// captureSummaryTimeout — the budget for one Stop, which is a map-reduce over
// the whole meeting: one model call per transcript window plus a reduce, each of
// which is seconds on this box. Forty windows is the configured ceiling, so the
// budget has to be minutes, not the 60s the reply path uses.
const captureSummaryTimeout = 20 * time.Minute
// llmCompleter adapts *llm.Client to capture.Completer. The pure package names
// the two strings it needs and stays free of the llm request struct; the client
// itself is the swap-aware one from llmClientFor, so a model swap re-points it.
type llmCompleter struct {
c *llm.Client
maxTokens int
}
func (l llmCompleter) Complete(ctx context.Context, system, user string) (string, error) {
return l.c.Complete(ctx, llm.Req{System: system, User: user, MaxTokens: l.maxTokens})
}
// captureWiring — the recorder plus what it needs to write the result down.
type captureWiring struct {
rec *capture.Recorder
st *store.Store
emb router.Embedder
cfg *config.CaptureConfig
now func() time.Time
}
// newCaptureWiring returns nil when the recorder should not exist: no media
// store, no capture block, capture disabled, or no STT to transcribe with.
//
// A missing llama-server is NOT a reason to return nil. Without one the
// recording is still made, stored and transcribed, and the summary is simply
// absent — the honest degradation, and much better than refusing to record a
// meeting that is happening now.
func newCaptureWiring(keeper *mediaKeeper, st *store.Store, voiceW *voiceWiring, phr phraser.Phraser, emb router.Embedder, cfg *config.Config) *captureWiring {
if keeper == nil || !cfg.Capture.Records() {
return nil
}
tr := transcriberOf(voiceW)
if tr == nil {
// Voice off ⇒ no STT client ⇒ nothing could turn the audio into words.
// Storing hours of unreadable audio of other people is worse than not
// recording, so this is a refusal, not a degradation.
log.Printf("capture: enabled but voice/stt is not wired — meeting capture disabled")
return nil
}
cc := cfg.Capture
var sum *capture.Summarizer
if lp, ok := phr.(*phraser.LLMPhraser); ok {
client := llmClientFor(lp, captureSummaryTimeout)
sum = capture.NewSummarizer(
llmCompleter{c: client, maxTokens: 512},
cc.ChunkRunes, cc.MaxChunks, contextBlockFn(cfg, time.Now),
)
} else {
log.Printf("capture: no llama-server phraser — meetings are transcribed, not summarised")
}
rec, err := capture.New(keeper.store, tr, sum, capture.Config{
MaxDuration: cc.MaxDuration(),
STTWindow: time.Duration(cc.STTWindow),
})
if err != nil {
log.Printf("capture: %v — meeting capture disabled", err)
return nil
}
log.Printf("capture: enabled, sessions capped at %s", rec.MaxDuration())
return &captureWiring{rec: rec, st: st, emb: emb, cfg: cc, now: time.Now}
}
// start handles ipc.MethodCaptureStart.
func (c *captureWiring) start(_ context.Context, req ipc.CaptureStartReq) (ipc.CaptureStartResp, error) {
s, err := c.rec.Start(req.Label)
if err != nil {
return ipc.CaptureStartResp{}, err
}
// The label is logged; nothing that was said ever is.
log.Printf("capture: started %q", s.Label)
return ipc.CaptureStartResp{
Label: s.Label,
Started: s.Started,
MaxSeconds: int(c.rec.MaxDuration().Seconds()),
}, nil
}
// append handles ipc.MethodCaptureAppend. ErrExpired is reported as a successful
// response with Expired set rather than an error: the cap firing is the designed
// behaviour, and the client needs the flag to stop sending and call stop.
func (c *captureWiring) append(_ context.Context, req ipc.CaptureAppendReq) (ipc.CaptureAppendResp, error) {
err := c.rec.Append(req.Audio)
st := c.rec.Status()
if errors.Is(err, capture.ErrExpired) {
log.Printf("capture: %q hit the %s cap — stopping", st.Label, c.rec.MaxDuration())
return ipc.CaptureAppendResp{Seconds: st.Duration.Seconds(), Expired: true}, nil
}
if err != nil {
return ipc.CaptureAppendResp{}, err
}
return ipc.CaptureAppendResp{Seconds: st.Duration.Seconds()}, nil
}
// stop handles ipc.MethodCaptureStop.
//
// The error handling here mirrors vision's, and for the same reason: the audio is
// stored first, so a transcription or summary failure returns what exists rather
// than nothing. A response can carry a blob id with no transcript (STT failed,
// re-runnable), or a transcript with no summary (the model failed, the words are
// kept) — both are degraded successes and neither is an error to the caller.
func (c *captureWiring) stop(ctx context.Context, req ipc.CaptureStopReq) (ipc.CaptureStopResp, error) {
if req.Discard {
// "забудь, не записывай" — nothing is stored, transcribed or noted.
if !c.rec.Abort() {
return ipc.CaptureStopResp{}, capture.ErrNoSession
}
log.Printf("capture: session discarded on request")
return ipc.CaptureStopResp{Discarded: true}, nil
}
res, err := c.rec.Stop(ctx)
resp := ipc.CaptureStopResp{
BlobID: res.BlobID,
Label: res.Label,
Started: res.Started,
Seconds: res.Duration.Seconds(),
Transcript: res.Transcript,
Summary: res.Summary,
Chunks: res.Chunks,
}
if err != nil {
if res.BlobID == "" && res.Transcript == "" {
// Nothing survived: no session, or an empty recording. There is
// nothing to hand back, so this is a real error.
return ipc.CaptureStopResp{}, err
}
log.Printf("capture: %q partially finished: %v", res.Label, err)
}
if id, werr := c.writeNotes(ctx, res); werr != nil {
log.Printf("capture: note write for %q failed: %v", res.Label, werr)
} else {
resp.NoteID = id
}
log.Printf("capture: finished %q — %s of audio, %d summary chunk(s)",
res.Label, res.Duration.Round(time.Second), res.Chunks)
return resp, nil
}
// writeNotes stores the summary as a note, and the transcript too when
// capture.save_transcript is set. Returns the summary note's id, or 0 when there
// was no summary to write.
//
// The note source carries the blob id, which is the only link back to the audio.
// When retention prunes the blob the note remains — words about a meeting are a
// far lighter thing to keep than a recording of it.
func (c *captureWiring) writeNotes(ctx context.Context, res capture.Result) (int64, error) {
source := "capture:meeting"
if res.BlobID != "" {
source = "capture:meeting:" + res.BlobID[:12]
}
var id int64
if text := res.Summary; text != "" {
var err error
id, err = c.writeNote(ctx, text, source)
if err != nil {
return 0, fmt.Errorf("summary note: %w", err)
}
}
if c.cfg.SaveTranscript && res.Transcript != "" {
if _, err := c.writeNote(ctx, res.Transcript, source+":transcript"); err != nil {
return id, fmt.Errorf("transcript note: %w", err)
}
}
return id, nil
}
func (c *captureWiring) writeNote(ctx context.Context, text, source string) (int64, error) {
var vec []float32
if c.emb != nil {
// EmbedPassage, not Embed: this is text being searched FOR, and the e5
// embedder is asymmetric. Backwards here makes the meeting unfindable by
// the question that should have matched it.
var err error
vec, err = router.EmbedPassage(ctx, c.emb, text)
if err != nil {
return 0, fmt.Errorf("embed: %w", err)
}
}
return c.st.WriteNote(ctx, c.now(), text, vec, source)
}
// status handles ipc.MethodCaptureStatus.
func (c *captureWiring) status(_ context.Context) (ipc.CaptureStatusResp, error) {
st := c.rec.Status()
return ipc.CaptureStatusResp{
Running: st.Running,
Label: st.Label,
Started: st.Started,
Seconds: st.Duration.Seconds(),
Bytes: st.Bytes,
}, nil
}
// wireCapture installs the four IPC hooks, or leaves them nil so every capture
// method reports ErrUnknownMethod. Takes the media keeper wireVision already
// opened: one blob store, one retention loop, images and audio side by side.
func wireCapture(srv *ipc.Server, keeper *mediaKeeper, st *store.Store, voiceW *voiceWiring, phr phraser.Phraser, cfg *config.Config) {
cw := newCaptureWiring(keeper, st, voiceW, phr, embedderOf(voiceW), cfg)
if cw == nil {
return
}
srv.CaptureStartFn = cw.start
srv.CaptureAppendFn = cw.append
srv.CaptureStopFn = cw.stop
srv.CaptureStatusFn = cw.status
}
+190
View File
@@ -0,0 +1,190 @@
package main
import (
"bytes"
"context"
"crypto/rand"
"io"
"os"
"path/filepath"
"testing"
"time"
"github.com/kami/maven/internal/store"
"github.com/kami/maven/internal/webauthn"
)
func randBytes(t *testing.T, n int) []byte {
t.Helper()
b := make([]byte, n)
if _, err := io.ReadFull(rand.Reader, b); err != nil {
t.Fatalf("rand: %v", err)
}
b[0] |= 1
return b
}
func TestDaemonLockStartsLockedAndFlips(t *testing.T) {
dl := newDaemonLock(true)
if !dl.isLocked() {
t.Fatal("newDaemonLock(true) is not locked")
}
dl.unlock(nil)
if dl.isLocked() {
t.Fatal("still locked after unlock")
}
if newDaemonLock(false).isLocked() {
t.Fatal("newDaemonLock(false) reports locked")
}
}
// closeStore must be safe on a daemon that never unlocked and safe twice —
// shutdown runs it unconditionally.
func TestDaemonLockCloseStoreIsSafeWhenNeverUnlocked(t *testing.T) {
dl := newDaemonLock(true)
if err := dl.closeStore(); err != nil {
t.Fatalf("closeStore with no store: %v", err)
}
if err := dl.closeStore(); err != nil {
t.Fatalf("second closeStore: %v", err)
}
}
// The data-loss bug: in locked mode the store is opened on an IPC goroutine
// inside UnlockFn, and shutdown runs on main. Without the handoff nothing
// calls Close, and Close is what re-encrypts the tmpfs working copy back over
// the ciphertext file — so every write of a cold-started session vanished.
func TestDaemonLockSealsTheStoreOpenedAfterUnlock(t *testing.T) {
dir := t.TempDir()
dbPath := filepath.Join(dir, "maven.db")
tmpfs := filepath.Join(dir, "work")
key := randBytes(t, 32)
// Store.Close zeroes the key slice it was handed (encState.key is the
// caller's backing array), so the next boot needs its own copy — exactly
// as mavend keeps envKeyBytes separate from the config's key.
nextBoot := bytes.Clone(key)
ctx := context.Background()
// Cold start: locked, no store.
dl := newDaemonLock(true)
// ... unlock arrives, opens the store and hands it over.
st, err := store.OpenEncrypted(ctx, dbPath, tmpfs, key)
if err != nil {
t.Fatalf("OpenEncrypted: %v", err)
}
dl.unlock(st)
if _, err := st.WriteNote(ctx, time.Now(), "заметка после холодного старта", nil, "test"); err != nil {
t.Fatalf("WriteNote: %v", err)
}
// Shutdown.
if err := dl.closeStore(); err != nil {
t.Fatalf("closeStore: %v", err)
}
if err := dl.closeStore(); err != nil {
t.Fatalf("second closeStore after a real store: %v", err)
}
// Next boot with the same key must see the write.
st2, err := store.OpenEncrypted(ctx, dbPath, tmpfs, nextBoot)
if err != nil {
t.Fatalf("reopen: %v", err)
}
defer st2.Close()
notes, err := st2.RecentNotes(ctx, 10)
if err != nil {
t.Fatalf("RecentNotes: %v", err)
}
if len(notes) != 1 {
t.Fatalf("got %d notes after a cold-started session, want 1 — the session was lost", len(notes))
}
}
// The whole point of the wrapped blob: what sits in the state dir must not let
// anyone open the database. Nothing written there may contain the key, and the
// ciphertext must not be readable with a wrong one.
func TestColdStartLeavesNoPlaintextKeyOnDisk(t *testing.T) {
dir := t.TempDir()
dbPath := filepath.Join(dir, "maven.db")
tmpfs := filepath.Join(dir, "work")
wrappedPath := filepath.Join(dir, "db_key.wrapped")
key := randBytes(t, 32)
secret := randBytes(t, 32)
ctx := context.Background()
blob, err := webauthn.WrapKey(key, secret)
if err != nil {
t.Fatalf("WrapKey: %v", err)
}
if err := os.WriteFile(wrappedPath, blob, 0o600); err != nil {
t.Fatalf("write wrapped key: %v", err)
}
st, err := store.OpenEncrypted(ctx, dbPath, tmpfs, key)
if err != nil {
t.Fatalf("OpenEncrypted: %v", err)
}
if _, err := st.WriteNote(ctx, time.Now(), "секрет", nil, "test"); err != nil {
t.Fatalf("WriteNote: %v", err)
}
if err := st.Close(); err != nil {
t.Fatalf("Close: %v", err)
}
// Walk everything in the state dir; none of it may contain the key.
err = filepath.Walk(dir, func(p string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() {
return err
}
b, rerr := os.ReadFile(p)
if rerr != nil {
return nil // unreadable is not a leak
}
if bytes.Contains(b, key) {
t.Errorf("%s contains the plaintext encryption key", p)
}
return nil
})
if err != nil {
t.Fatalf("walk: %v", err)
}
// The wrapped file must have owner-only permissions.
fi, err := os.Stat(wrappedPath)
if err != nil {
t.Fatalf("stat: %v", err)
}
if perm := fi.Mode().Perm(); perm != 0o600 {
t.Errorf("wrapped key file mode = %o, want 600", perm)
}
// A wrong passkey must not open the store.
if _, _, err := webauthn.UnwrapKey(blob, randBytes(t, 32)); err == nil {
t.Fatal("a wrong PRF secret unwrapped the key")
}
if _, err := store.OpenEncrypted(ctx, dbPath, filepath.Join(dir, "work2"), randBytes(t, 32)); err == nil {
t.Fatal("the encrypted store opened under a wrong key")
}
// And the right one round-trips back to a readable database.
got, version, err := webauthn.UnwrapKey(blob, secret)
if err != nil {
t.Fatalf("UnwrapKey: %v", err)
}
if version != webauthn.BlobV2 {
t.Errorf("blob version = %v, want v2", version)
}
st2, err := store.OpenEncrypted(ctx, dbPath, tmpfs, got)
if err != nil {
t.Fatalf("reopen with the unwrapped key: %v", err)
}
defer st2.Close()
notes, err := st2.RecentNotes(ctx, 10)
if err != nil {
t.Fatalf("RecentNotes: %v", err)
}
if len(notes) != 1 {
t.Fatalf("got %d notes, want 1", len(notes))
}
}
+184
View File
@@ -0,0 +1,184 @@
// mavend/crawls.go — the driver for reading web pages (Vikunja #259,
// docs/plans/14-web-crawler.md). The crawler is pure and lives in
// internal/crawl; this is the impure half: the guarded fetcher, a ticker for the
// scheduled watches, and the fact-backed dedup hashes.
//
// Two paths, one config block, both off unless configured:
//
// - ON DEMAND — he names a URL out loud and she reads it. That is the
// `queryWeb` source in actions_query.go, LAST in the chain: after his
// memory, after the notes, and (once Kiwix is wired into the chain) after
// the local ZIMs. A local read costs nothing and leaks nothing; a fetch puts
// a URL in someone's log, so it goes last.
// - SCHEDULED — a watched page is re-read on its interval, and a page whose
// text changed is written as a note. It does NOT announce itself. Same rule
// as the feed poller: notes, never nudges.
//
// Only the URL goes out. Nothing here reads a note, a fact, the persona block or
// the history, and internal/crawl has no access to the store at all.
package main
import (
"context"
"log"
"net/url"
"time"
"github.com/kami/maven/internal/config"
"github.com/kami/maven/internal/crawl"
"github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/router"
"github.com/kami/maven/internal/webfetch"
)
// newCrawler builds the crawler from the `crawl` block, or returns nil when
// there is none. Every caller checks for nil, and nil means no page is ever
// fetched.
func newCrawler(cfg *config.Config) *crawl.Crawler {
if cfg.Crawl == nil {
return nil
}
cc := cfg.Crawl
hosts := append([]string(nil), cc.AllowHosts...)
// A watched page's own host is always reachable; otherwise an allowlist and
// a watch list would have to be kept in sync by hand.
for _, w := range cc.Watches {
if u, err := url.Parse(w.URL); err == nil && u.Hostname() != "" {
hosts = append(hosts, u.Hostname())
}
}
// An allowlist plus on-demand is a contradiction worth logging rather than
// silently resolving: he asked for arbitrary pages AND for a fixed list.
// The allowlist wins, because it is the narrower instruction.
if len(hosts) > 0 && cc.OnDemand && len(cc.AllowHosts) > 0 {
log.Printf("crawl: allow_hosts is set, so on-demand reading is limited to those hosts")
}
ua := cc.UserAgent
if ua == "" {
ua = webfetch.DefaultUserAgent
}
fetcher := webfetch.New(webfetch.Config{
AllowHosts: hosts,
DenyHosts: cc.DenyHosts,
Timeout: time.Duration(cc.Timeout),
MaxBytes: cc.MaxBytes,
UserAgent: ua,
})
// The user-agent handed to the crawler is the one the fetcher sends: obeying
// robots rules written for a different name would be a lie.
return crawl.New(&crawlFetcher{f: fetcher}, crawl.Config{
UserAgent: ua,
MaxRunes: cc.MaxRunes,
})
}
// onDemandCrawler returns a crawler for the answer path, or nil when on-demand
// reading is off. The scheduled watches can be on while this is off: reading a
// fixed list of pages on a timer and reading whatever URL is in an utterance are
// different permissions, and the config keeps them separate.
func onDemandCrawler(cfg *config.Config) *crawl.Crawler {
if cfg.Crawl == nil || !cfg.Crawl.OnDemand {
return nil
}
return newCrawler(cfg)
}
// crawlWorker — ticker + watcher for the scheduled half.
type crawlWorker struct {
watcher *crawl.Watcher
interval time.Duration
}
// crawlTickInterval — how often the worker asks what is due. Per-watch cadence
// is the watcher's business.
const crawlTickInterval = 15 * time.Minute
// newCrawlWorker wires the scheduled crawls, or nil when nothing is watched.
func newCrawlWorker(c *crawl.Crawler, api ipc.CoreAPI, emb router.Embedder, cfg *config.Config) *crawlWorker {
if c == nil || cfg.Crawl == nil || len(cfg.Crawl.Watches) == 0 {
return nil
}
watches := make([]crawl.WatchConfig, 0, len(cfg.Crawl.Watches))
for _, w := range cfg.Crawl.Watches {
watches = append(watches, crawl.WatchConfig{
Name: w.Name,
URL: w.URL,
Interval: time.Duration(w.Interval),
})
}
watcher := crawl.NewWatcher(c, watches, api, &factHashes{api: api},
crawlEmbedder(emb), time.Duration(cfg.Crawl.Interval))
if watcher == nil {
log.Printf("crawl: configured but nothing watchable — scheduled crawls disabled")
return nil
}
log.Printf("crawl: watching %d page(s), checking what is due every %s", len(watches), crawlTickInterval)
return &crawlWorker{watcher: watcher, interval: crawlTickInterval}
}
// run checks what is due until ctx is canceled. The first round runs
// immediately; it writes notes only, so an early round startles nobody.
func (w *crawlWorker) run(ctx context.Context) {
w.watcher.CheckDue(ctx, time.Now())
t := time.NewTicker(w.interval)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case now := <-t.C:
w.watcher.CheckDue(ctx, now)
}
}
}
// crawlFetcher adapts webfetch to crawl.Fetcher, which is the seam that keeps
// net/http out of the crawler package.
type crawlFetcher struct{ f *webfetch.Fetcher }
func (a *crawlFetcher) Get(ctx context.Context, u string) (*crawl.Response, error) {
resp, err := a.f.Get(ctx, u)
if err != nil {
return nil, err
}
return &crawl.Response{URL: resp.URL, ContentType: resp.ContentType, Body: resp.Body}, nil
}
// factHashes stores each watch's last content hash as a config fact, so a
// restart does not re-note an unchanged page. Same mechanism the feed reader
// uses for its marks, and inspectable on /dash.
type factHashes struct{ api ipc.CoreAPI }
func hashKey(name string) string { return "crawl:hash:" + name }
func (h *factHashes) LastHash(ctx context.Context, name string) (string, error) {
f, err := h.api.LatestFact(ctx, hashKey(name))
if err != nil {
// No hash yet is not an error: the watcher treats "" as "never read".
return "", nil
}
return f.Value, nil
}
func (h *factHashes) SetHash(ctx context.Context, name, hash string) error {
_, err := h.api.WriteFact(ctx, ipc.WriteFactReq{
Ts: time.Now(),
Kind: "config",
Key: hashKey(name),
Value: hash,
Source: "poll:crawl",
Confidence: 1.0,
})
return err
}
// crawlEmbedder adapts router.Embedder for the watcher, embedding with
// EmbedPassage (a page is text being searched FOR, and the e5 embedder is
// asymmetric).
func crawlEmbedder(emb router.Embedder) crawl.Embedder {
if emb == nil {
return nil
}
return passageEmbedder{emb}
}
+186
View File
@@ -0,0 +1,186 @@
package main
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/kami/maven/internal/config"
"github.com/kami/maven/internal/crawl"
"github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/phraser"
"github.com/kami/maven/internal/router"
"github.com/kami/maven/internal/voice"
)
// The default config reads nothing. This is the whole "off unless configured"
// contract for the crawler, asserted at the wiring level rather than trusted.
func TestCrawlOffByDefault(t *testing.T) {
cfg := &config.Config{}
if c := newCrawler(cfg); c != nil {
t.Error("newCrawler with no crawl block returned a crawler")
}
if c := onDemandCrawler(cfg); c != nil {
t.Error("onDemandCrawler with no crawl block returned a crawler")
}
if w := newCrawlWorker(nil, nil, nil, cfg); w != nil {
t.Error("newCrawlWorker with no crawl block returned a worker")
}
// Watches configured but on_demand off ⇒ the answer path still reads
// nothing: a timer over a fixed list is not permission for arbitrary URLs.
withWatch := &config.Config{Crawl: &config.CrawlConfig{
Watches: []config.CrawlWatchConfig{{Name: "p", URL: "https://example.org/p"}},
}}
if c := onDemandCrawler(withWatch); c != nil {
t.Error("onDemandCrawler honoured a watch list as on-demand permission")
}
if c := newCrawler(withWatch); c == nil {
t.Error("newCrawler returned nil for a configured watch")
}
}
// The wired fetcher must refuse a private address, because the crawler on this
// box sits one hop from the whole homelab. Same guard the webfetch tests cover;
// this asserts the daemon actually wires it.
func TestCrawlerRefusesPrivateAddress(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
w.Write([]byte("<html><body>secret</body></html>"))
}))
defer srv.Close()
c := newCrawler(&config.Config{Crawl: &config.CrawlConfig{OnDemand: true}})
if c == nil {
t.Fatal("newCrawler returned nil for an on-demand config")
}
if _, err := c.Page(context.Background(), srv.URL); err == nil {
t.Fatalf("reading %s succeeded; a loopback address must be refused", srv.URL)
}
}
func TestFactHashesRoundTrip(t *testing.T) {
ctx := context.Background()
st := newTestStore(t)
h := &factHashes{api: ipc.NewStoreAPI(st)}
got, err := h.LastHash(ctx, "page")
if err != nil {
t.Fatalf("LastHash on a fresh store: %v", err)
}
if got != "" {
t.Errorf("LastHash = %q, want empty for a never-read page", got)
}
if err := h.SetHash(ctx, "page", "deadbeef"); err != nil {
t.Fatalf("SetHash: %v", err)
}
got, err = h.LastHash(ctx, "page")
if err != nil {
t.Fatalf("LastHash: %v", err)
}
if got != "deadbeef" {
t.Errorf("LastHash = %q, want deadbeef", got)
}
if key := hashKey("page"); key != "crawl:hash:page" {
t.Errorf("hashKey = %q", key)
}
}
// stubCrawlFetcher serves one fixed page to every URL, so queryWeb can be
// exercised without a network or an allowlist.
type stubCrawlFetcher struct{ body, ctype string }
func (s *stubCrawlFetcher) Get(_ context.Context, u string) (*crawl.Response, error) {
ct := s.ctype
if ct == "" {
ct = "text/html"
}
if strings.HasSuffix(u, "/robots.txt") {
return &crawl.Response{URL: u, ContentType: "text/plain", Body: []byte("")}, nil
}
return &crawl.Response{URL: u, ContentType: ct, Body: []byte(s.body)}, nil
}
func buildWebHandler(c *crawl.Crawler) *reactiveHandler {
return &reactiveHandler{
replier: voice.NewStubReplier(),
phraser: phraser.NewStub(),
crawler: c,
}
}
func askWeb(h *reactiveHandler, q string) (string, bool) {
return h.queryWeb(context.Background(), &queryTurn{
dec: router.Decision{Intent: router.IntentQuery, Utterance: q},
})
}
func TestQueryWebPassesWithoutAURL(t *testing.T) {
h := buildWebHandler(crawl.New(&stubCrawlFetcher{body: "<html><body>x</body></html>"}, crawl.Config{}))
if reply, ok := askWeb(h, "почему небо синее?"); ok {
t.Errorf("the web source claimed a question with no URL: %q", reply)
}
}
// Not configured is said out loud rather than falling through, so a small model
// never invents a page's contents from its URL.
func TestQueryWebSaysWhenNotConfigured(t *testing.T) {
h := buildWebHandler(nil)
reply, ok := askWeb(h, "посмотри https://example.org/page")
if !ok {
t.Fatal("the web source did not claim a question with a URL")
}
if !strings.Contains(reply, "не настроено") {
t.Errorf("reply = %q, want the not-configured answer", reply)
}
}
func TestQueryWebReadsThePage(t *testing.T) {
h := buildWebHandler(crawl.New(&stubCrawlFetcher{
body: "<html><head><title>Заголовок</title></head><body><p>текст страницы</p></body></html>",
}, crawl.Config{}))
reply, ok := askWeb(h, "посмотри https://example.org/page — что там?")
if !ok {
t.Fatal("the web source did not claim a question with a URL")
}
if !strings.Contains(reply, "текст страницы") {
t.Errorf("reply = %q, want the page text read back", reply)
}
}
func TestQueryWebRefusesNonHTML(t *testing.T) {
h := buildWebHandler(crawl.New(&stubCrawlFetcher{
body: "\x00\x01binary", ctype: "application/octet-stream",
}, crawl.Config{}))
reply, ok := askWeb(h, "почитай https://example.org/blob.bin")
if !ok {
t.Fatal("the web source did not claim a question with a URL")
}
if !strings.Contains(reply, "не получилось") {
t.Errorf("reply = %q, want the read-failed answer", reply)
}
}
// robots.txt is honoured on the answer path too, and she says so instead of
// reporting a generic failure.
func TestQueryWebObeysRobots(t *testing.T) {
h := buildWebHandler(crawl.New(&robotsDenyFetcher{}, crawl.Config{}))
reply, ok := askWeb(h, "посмотри https://example.org/private")
if !ok {
t.Fatal("the web source did not claim a question with a URL")
}
if !strings.Contains(reply, "robots.txt") {
t.Errorf("reply = %q, want the robots answer", reply)
}
}
type robotsDenyFetcher struct{}
func (robotsDenyFetcher) Get(_ context.Context, u string) (*crawl.Response, error) {
if strings.HasSuffix(u, "/robots.txt") {
return &crawl.Response{URL: u, ContentType: "text/plain",
Body: []byte("User-agent: *\nDisallow: /private\n")}, nil
}
return &crawl.Response{URL: u, ContentType: "text/html", Body: []byte("<html>nope</html>")}, nil
}
+194
View File
@@ -0,0 +1,194 @@
// mavend/feeds.go — the driver for RSS/Atom reading (Vikunja #258,
// docs/plans/13-rss-news-feeds.md). The reader itself is pure and lives in
// internal/rss; this is the impure half: a ticker, the guarded fetcher, and the
// two adapters that let a pure package talk to the store.
//
// Why in-core rather than its own daemon like mavmaild and mavpoll: those two
// hold a CREDENTIAL (an IMAP password, a zenmoney token), and the reason they
// are separate processes is that core must never see it. A feed URL is public,
// there is no secret to isolate, and a whole extra binary and compose service
// would buy nothing. The other half of the mavpoll precedent — off unless
// configured — is kept: no `feeds` block, no poller, no outbound request.
//
// It is its own goroutine, not a step on the tick: the tick has a delivery
// deadline behind it, and a feed read is a network round-trip that nobody is
// waiting on.
//
// Nothing here dispatches. A feed that announced itself would be a nag, so the
// only output is notes with source "rss:<feed>", which the answer path reads
// when he asks ("что нового в лентах?" — see queryFeeds in actions_query.go).
package main
import (
"context"
"log"
"net/url"
"time"
"github.com/kami/maven/internal/config"
"github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/router"
"github.com/kami/maven/internal/rss"
"github.com/kami/maven/internal/stt"
"github.com/kami/maven/internal/webfetch"
)
// feedWorker — ticker + poller.
type feedWorker struct {
poller *rss.Poller
interval time.Duration
}
// feedTickInterval — how often the worker asks the poller what is due. Per-feed
// cadence is the poller's business; this is just the granularity.
const feedTickInterval = 5 * time.Minute
// newFeedWorker wires feed reading, or returns nil when it must not run:
// no `feeds` block (the normal case), or nothing valid in it. Every caller
// checks for nil.
func newFeedWorker(api ipc.CoreAPI, emb router.Embedder, cfg *config.Config) *feedWorker {
if cfg.Feeds == nil {
return nil
}
fc := cfg.Feeds
feeds := make([]rss.FeedConfig, 0, len(fc.Sources))
hosts := append([]string(nil), fc.AllowHosts...)
for _, s := range fc.Sources {
feeds = append(feeds, rss.FeedConfig{
Name: s.Name,
URL: s.URL,
Category: s.Category,
Interval: time.Duration(s.Interval),
Include: s.Include,
Exclude: s.Exclude,
})
// Each configured feed's own host is allowed. The allowlist is then
// exactly "the feeds he asked for", so a redirect off to somewhere else
// is refused by the fetcher rather than followed.
if u, err := url.Parse(s.URL); err == nil && u.Hostname() != "" {
hosts = append(hosts, u.Hostname())
}
}
fetcher := webfetch.New(webfetch.Config{
AllowHosts: hosts,
Timeout: time.Duration(fc.Timeout),
MaxBytes: fc.MaxBytes,
})
poller := rss.NewPoller(feeds, &feedFetcher{f: fetcher}, api, &factMarks{api: api},
embedderFor(emb), nil, rss.Config{
DefaultInterval: time.Duration(fc.PollInterval),
MaxItems: fc.MaxItems,
MaxAge: time.Duration(fc.MaxAge),
})
if poller == nil {
log.Printf("feeds: configured but nothing pollable — feed reading disabled")
return nil
}
log.Printf("feeds: reading %d feed(s), checking what is due every %s", len(feeds), feedTickInterval)
return &feedWorker{poller: poller, interval: feedTickInterval}
}
// run polls what is due until ctx is canceled. The first round runs immediately
// so a restart does not blind her for the first interval; it writes notes only,
// so an early round cannot startle anyone.
func (w *feedWorker) run(ctx context.Context) {
w.poller.PollDue(ctx, time.Now())
t := time.NewTicker(w.interval)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case now := <-t.C:
w.poller.PollDue(ctx, now)
}
}
}
// embedderOf — the voice wiring's embedder, or nil when voice is not wired.
// Feed notes are embedded with the SAME model the rest of the store uses, or not
// at all; a second embedder would write vectors nothing can search.
func embedderOf(w *voiceWiring) router.Embedder {
if w == nil {
return nil
}
return w.embedder
}
// transcriberOf — the STT the voice path is using, or nil when voice is off.
// The meeting recorder reuses it rather than dialling mavsttd a second time:
// Maven has one speech-to-text engine and adding a second would mean two
// whisper contexts competing for the same iGPU.
func transcriberOf(w *voiceWiring) stt.Transcriber {
if w == nil {
return nil
}
return w.transcriber
}
// feedFetcher adapts webfetch to rss.Fetcher — the pure package names the two
// fields it needs and stays free of net/http.
type feedFetcher struct{ f *webfetch.Fetcher }
func (a *feedFetcher) Get(ctx context.Context, u string) (*rss.Body, error) {
resp, err := a.f.Get(ctx, u)
if err != nil {
return nil, err
}
return &rss.Body{Bytes: resp.Body}, nil
}
// factMarks stores "how far this feed was read" as a config fact, the same
// mechanism the plan named and the same one the pattern tick uses for its own
// bookkeeping. Durable, inspectable on /dash, and cheap.
type factMarks struct{ api ipc.CoreAPI }
func markKey(feed string) string { return "rss:latest:" + feed }
func (m *factMarks) LastMark(ctx context.Context, feed string) (time.Time, error) {
f, err := m.api.LatestFact(ctx, markKey(feed))
if err != nil {
// No mark yet is not an error worth propagating: the poller treats a
// zero time as a cold start.
return time.Time{}, nil
}
t, err := time.Parse(time.RFC3339, f.Value)
if err != nil {
return time.Time{}, nil
}
return t, nil
}
func (m *factMarks) SetMark(ctx context.Context, feed string, at time.Time) error {
_, err := m.api.WriteFact(ctx, ipc.WriteFactReq{
Ts: time.Now(),
Kind: "config",
Key: markKey(feed),
Value: at.UTC().Format(time.RFC3339),
Source: "poll:rss",
Confidence: 1.0,
})
return err
}
// embedderFor adapts router.Embedder to rss.Embedder, and returns nil when
// there is none — a note without a vector is still a note the recent-notes path
// can read.
//
// EmbedPassage, not Embed: a feed item is text being searched FOR, and the e5
// embedder is asymmetric. Getting this backwards makes the item unfindable by
// the question that should have matched it.
func embedderFor(emb router.Embedder) rss.Embedder {
if emb == nil {
return nil
}
return passageEmbedder{emb}
}
type passageEmbedder struct{ e router.Embedder }
func (p passageEmbedder) Embed(ctx context.Context, text string) ([]float32, error) {
return router.EmbedPassage(ctx, p.e, text)
}
+177
View File
@@ -0,0 +1,177 @@
package main
import (
"context"
"strings"
"testing"
"time"
"github.com/kami/maven/internal/config"
"github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/phraser"
"github.com/kami/maven/internal/router"
"github.com/kami/maven/internal/rss"
"github.com/kami/maven/internal/voice"
)
// buildFeedHandler — a handler with the given feed notes already stored. No
// embedder: the feed source answers from recent notes by source, which is what
// makes it work for notes written before an embedder existed.
func buildFeedHandler(t *testing.T, feedsOn bool, notes ...ipc.Note) *reactiveHandler {
t.Helper()
ctx := context.Background()
st := newTestStore(t)
now := time.Now()
for i, n := range notes {
ts := now.Add(time.Duration(i) * time.Minute)
if _, err := st.WriteNote(ctx, ts, n.Text, nil, n.Source); err != nil {
t.Fatalf("WriteNote: %v", err)
}
}
return &reactiveHandler{
api: ipc.NewStoreAPI(st),
replier: voice.NewStubReplier(),
phraser: phraser.NewStub(),
now: func() time.Time { return now },
feedsOn: feedsOn,
embedder: nil,
}
}
func askFeeds(t *testing.T, h *reactiveHandler, q string) (string, bool) {
t.Helper()
return h.queryFeeds(context.Background(), &queryTurn{
dec: router.Decision{Intent: router.IntentQuery, Utterance: q},
})
}
func TestQueryFeedsReadsFeedNotes(t *testing.T) {
h := buildFeedHandler(t, true,
ipc.Note{Text: "Новая уязвимость в ядре [технологии]\nпатч вышел\nhttps://example.org/a", Source: "rss:habr"},
ipc.Note{Text: "что-то он сам сказал", Source: "tap:voice"},
)
reply, ok := askFeeds(t, h, "что нового в лентах?")
if !ok {
t.Fatal("the feed source did not claim the question")
}
if !strings.Contains(reply, "уязвимость") {
t.Errorf("reply = %q, want the headline", reply)
}
if strings.Contains(reply, "он сам сказал") {
t.Errorf("a note he dictated leaked into the feed answer: %q", reply)
}
// She reads the headline, not the summary and not the URL.
if strings.Contains(reply, "https://") || strings.Contains(reply, "патч вышел") {
t.Errorf("reply = %q, want the title line only", reply)
}
}
func TestQueryFeedsByCategory(t *testing.T) {
h := buildFeedHandler(t, true,
ipc.Note{Text: "Релиз ядра [технологии]", Source: "rss:habr"},
ipc.Note{Text: "Выборы отложены [политика]", Source: "rss:news"},
)
reply, ok := askFeeds(t, h, "что нового по технологиям?")
if !ok {
t.Fatal("not claimed")
}
if !strings.Contains(reply, "ядра") || strings.Contains(reply, "Выборы") {
t.Fatalf("reply = %q, want only the технологии item", reply)
}
reply, _ = askFeeds(t, h, "что нового по спорту?")
if !strings.Contains(reply, "ничего") {
t.Fatalf("reply = %q, want an honest empty answer for an unread category", reply)
}
}
// "не настроены" and "ничего нового" are different truths, and neither may be
// answered by the model inventing a bulletin.
func TestQueryFeedsOffAndEmptyDiffer(t *testing.T) {
off := buildFeedHandler(t, false)
reply, ok := askFeeds(t, off, "что нового?")
if !ok || !strings.Contains(reply, "не настроены") {
t.Fatalf("feeds off: reply = %q, ok = %v", reply, ok)
}
on := buildFeedHandler(t, true)
reply, ok = askFeeds(t, on, "что нового?")
if !ok || !strings.Contains(reply, "ничего нового") {
t.Fatalf("feeds on but empty: reply = %q, ok = %v", reply, ok)
}
}
func TestQueryFeedsPassesOnANonFeedQuestion(t *testing.T) {
h := buildFeedHandler(t, true)
if reply, ok := askFeeds(t, h, "напомни полить цветы"); ok {
t.Fatalf("claimed an unrelated question with %q", reply)
}
}
// The mark is what stops a restart from re-noting yesterday's headlines, so the
// fact round-trip is worth a test of its own.
func TestFactMarksRoundTrip(t *testing.T) {
st := newTestStore(t)
m := &factMarks{api: ipc.NewStoreAPI(st)}
ctx := context.Background()
at, err := m.LastMark(ctx, "habr")
if err != nil || !at.IsZero() {
t.Fatalf("no mark yet: got %v, %v — want zero time and no error", at, err)
}
want := time.Date(2026, 7, 28, 10, 0, 0, 0, time.UTC)
if err := m.SetMark(ctx, "habr", want); err != nil {
t.Fatal(err)
}
got, err := m.LastMark(ctx, "habr")
if err != nil {
t.Fatal(err)
}
if !got.Equal(want) {
t.Fatalf("mark = %v, want %v", got, want)
}
}
// Off unless configured, checked at the wiring seam: no `feeds` block ⇒ no
// worker ⇒ no outbound request is possible.
func TestNewFeedWorkerOffByDefault(t *testing.T) {
st := newTestStore(t)
api := ipc.NewStoreAPI(st)
if w := newFeedWorker(api, nil, &config.Config{}); w != nil {
t.Fatal("a config with no feeds block wired a feed worker")
}
// An empty sources list is normalised to "off" by config.Load; the worker
// refuses it too, so a hand-built Config cannot switch it on by accident.
if w := newFeedWorker(api, nil, &config.Config{Feeds: &config.FeedsConfig{}}); w != nil {
t.Fatal("an empty sources list wired a feed worker")
}
cfg := &config.Config{Feeds: &config.FeedsConfig{Sources: []config.FeedSourceConfig{
{Name: "habr", URL: "https://example.org/rss"},
}}}
w := newFeedWorker(api, nil, cfg)
if w == nil {
t.Fatal("a configured feed did not wire a worker")
}
if got := w.poller.Feeds(); len(got) != 1 || got[0].Name != "habr" {
t.Fatalf("feeds = %+v", got)
}
}
// The fetcher the worker builds must be allowlisted to the configured feeds and
// nothing else — the crawler's SSRF guards are only worth as much as the
// allowlist handed to them.
func TestFeedWorkerFetcherIsAllowlisted(t *testing.T) {
cfg := &config.Config{Feeds: &config.FeedsConfig{Sources: []config.FeedSourceConfig{
{Name: "habr", URL: "https://feeds.example.org/rss"},
}}}
w := newFeedWorker(ipc.NewStoreAPI(newTestStore(t)), nil, cfg)
if w == nil {
t.Fatal("no worker")
}
// PollFeed goes through the guarded fetcher; a feed URL pointing at the box
// itself must fail rather than be read.
_, err := w.poller.PollFeed(context.Background(), rss.FeedConfig{
Name: "evil", URL: "http://127.0.0.1:9100/mcp",
}, time.Now())
if err == nil {
t.Fatal("the poller fetched a private address")
}
}
+235
View File
@@ -0,0 +1,235 @@
// mavend/intake.go — the unified event intake envelope, wired (Vikunja #283).
//
// internal/event defines the envelope and the bounded in-memory journal. This
// file is the one place that FILLS it, and the reason it is one place is worth
// stating, because the alternative was eight patches:
//
// Every intake path in Maven already converges on three writes, and all three
// are ipc.CoreAPI methods —
//
// WriteFact ← POST /api/ambient, mavcaldav, mavpoll's zenmoney + wg reads,
// /api/signal presence probes, the RSS/crawl watermarks
// WriteNote ← the RSS poller, the page crawler, meeting transcripts,
// image descriptions
// CaptureTask ← the voice path, the web form, and the mail reader
//
// — so decorating that ONE interface with a publish covers the lot without a
// caller knowing about events at all. cmd/mavmaild, cmd/mavcaldav, cmd/mavpoll,
// cmd/mavweb and the in-core feed/crawl/capture/vision workers are unchanged:
// they call the same interface they always called, and it now also narrates.
//
// The exception is cmd/mavend/mail.go, which reaches past the interface to
// st.CaptureTask directly. It publishes explicitly; see mailIntake.ingest.
//
// # Production behaviour when nobody is watching
//
// A nil *event.Bus makes Publish a no-op, and newIntakeAPI with a nil bus
// returns the wrapped API unchanged, so there is not even a decorator on the
// call path. The journal is memory-only and is never consulted by the tick
// loop, the router, or delivery — nothing Maven says depends on it. It is a
// read surface (`/events`, `recent_events`) and an observation seam for the
// simulator.
//
// # What is deliberately NOT here
//
// No dispatch. An event is a report that something arrived, never an
// instruction to speak: "a feed item appeared" becoming a notification is the
// nag this repo refuses. Digestion may one day read the journal; it will still
// go through internal/loop's rules and the severity/presence routing table.
package main
import (
"context"
"log"
"strings"
"time"
"github.com/kami/maven/internal/config"
"github.com/kami/maven/internal/event"
"github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/store"
)
// newEventBus builds the journal, or returns nil when the operator turned it
// off (a negative config.intake_journal). nil is the "behave exactly as before"
// value all the way down: no decorator, no ring, no /events rows.
func newEventBus(cfg *config.Config) *event.Bus {
if cfg == nil || cfg.IntakeJournal < 0 {
log.Printf("intake journal: off (intake_journal < 0)")
return nil
}
n := cfg.IntakeJournal
if n == 0 {
n = config.DefaultIntakeJournal
}
log.Printf("intake journal: keeping the last %d intake events in memory", n)
return event.NewBus(n)
}
// intakeEventsFn is the daemonAPI.getEvents closure: the bus's ring rendered as
// the wire type. Returns nil for a nil bus, which the daemonAPI reports as an
// empty journal rather than an error.
func intakeEventsFn(bus *event.Bus) func(n int) []ipc.IntakeEvent {
if bus == nil {
return nil
}
return func(n int) []ipc.IntakeEvent {
evs := bus.Recent(n)
out := make([]ipc.IntakeEvent, 0, len(evs))
for _, e := range evs {
out = append(out, ipc.IntakeEvent{
Source: e.Source,
Kind: e.Kind,
EntityIDs: e.EntityIDs,
Title: e.Title,
Body: e.Body,
Priority: e.Priority,
OccurredAt: e.OccurredAt,
})
}
return out
}
}
// intakeAPI decorates a CoreAPI, publishing one envelope per successful
// intake write. Embedding the interface means every other method passes
// through untouched, and a new CoreAPI method is inherited rather than
// silently dropped.
type intakeAPI struct {
ipc.CoreAPI
bus *event.Bus
now func() time.Time
}
// newIntakeAPI wraps api so its intake writes are journalled. A nil bus
// returns api itself — no decorator, no allocation, no behaviour change.
func newIntakeAPI(api ipc.CoreAPI, bus *event.Bus, now func() time.Time) ipc.CoreAPI {
if bus == nil || api == nil {
return api
}
if now == nil {
now = time.Now
}
return &intakeAPI{CoreAPI: api, bus: bus, now: now}
}
// WriteFact journals the fact after it lands. Order matters: an event is a
// report of something that HAPPENED, so a failed write publishes nothing.
func (a *intakeAPI) WriteFact(ctx context.Context, req ipc.WriteFactReq) (int64, error) {
id, err := a.CoreAPI.WriteFact(ctx, req)
if err != nil {
return id, err
}
// OccurredAt is req.Ts, not now: mavpoll's wg read carries the handshake
// instant and the ambient path carries the meeting's start. Flattening
// those to notice-time would make the journal lie about when things
// happened, which is the one thing it is for.
a.bus.Publish(event.Event{
Source: req.Source,
Kind: event.SourceKind(req.Source, event.KindFact),
Title: req.Key,
Body: req.Value,
Priority: factPriority(req),
OccurredAt: req.Ts,
EntityIDs: entityIDs(req.Subject),
}, a.now())
return id, nil
}
// WriteNote journals a note. This is the RSS and crawler path, and also the
// meeting transcript and image description paths, which write their derived
// text as ordinary notes.
func (a *intakeAPI) WriteNote(ctx context.Context, ts time.Time, text string, embedding []float32, source string) (int64, error) {
id, err := a.CoreAPI.WriteNote(ctx, ts, text, embedding, source)
if err != nil {
return id, err
}
title, body := splitFirstLine(text)
a.bus.Publish(event.Event{
Source: source,
Kind: event.SourceKind(source, event.KindNote),
Title: title,
Body: body,
Priority: event.PriorityLow,
OccurredAt: ts,
}, a.now())
return id, nil
}
// CaptureTask journals a captured task, but only when a row was actually
// created. CaptureTask dedupes on normalised text among live rows, so a
// mailbox re-read after a restart must not refill the journal with tasks that
// were already there.
func (a *intakeAPI) CaptureTask(ctx context.Context, req ipc.CaptureTaskReq) (ipc.CaptureTaskResp, error) {
resp, err := a.CoreAPI.CaptureTask(ctx, req)
if err != nil || !resp.Created {
return resp, err
}
a.bus.Publish(publishableTask(store.Task{
CreatedTs: req.Ts,
Text: req.Text,
Source: req.Source,
Evidence: req.Evidence,
Status: req.Status,
Due: req.Due,
}, a.now()), a.now())
return resp, nil
}
// publishableTask is the task→envelope shape, shared with mail.go, which
// captures through the store directly rather than through the interface.
//
// Priority is high for a candidate with a due date and normal otherwise. That
// is the only place this file makes a judgement, and it is a display hint on a
// review page — nothing routes on it.
func publishableTask(t store.Task, now time.Time) event.Event {
occurred := t.CreatedTs
if occurred.IsZero() {
occurred = now
}
prio := event.PriorityNormal
if t.Due != nil {
prio = event.PriorityHigh
}
return event.Event{
Source: t.Source,
Kind: event.KindTask,
Title: t.Text,
Body: t.Evidence,
Priority: prio,
OccurredAt: occurred,
}
}
// factPriority is the attention hint for a fact write. Deliberately crude:
// a low-confidence inference (the ambient notification path writes below 1.0)
// is worth less attention than a read he or a credentialled poller made, and
// nothing else is distinguishable from here.
func factPriority(req ipc.WriteFactReq) string {
if req.Confidence > 0 && req.Confidence < 1.0 {
return event.PriorityLow
}
return event.PriorityNormal
}
// entityIDs turns a fact's free-text Subject into the EntityIDs slot when it
// already looks resolved. Intake runs BEFORE the fact enrichment worker
// resolves a subject against Nexus, so this is almost always empty — the slot
// exists for the paths that do know (the ecosystem acts), not for guessing.
func entityIDs(subject string) []string {
subject = strings.TrimSpace(subject)
if subject == "" || !strings.HasPrefix(subject, "entity:") {
return nil
}
return []string{strings.TrimPrefix(subject, "entity:")}
}
// splitFirstLine renders a note as title + body. Feed and crawl notes are
// written "headline\nsummary\nlink", so the first line is already the title.
func splitFirstLine(text string) (title, body string) {
text = strings.TrimSpace(text)
if i := strings.IndexByte(text, '\n'); i >= 0 {
return strings.TrimSpace(text[:i]), strings.TrimSpace(text[i+1:])
}
return text, ""
}
+178
View File
@@ -0,0 +1,178 @@
package main
import (
"context"
"errors"
"testing"
"time"
"github.com/kami/maven/internal/config"
"github.com/kami/maven/internal/event"
"github.com/kami/maven/internal/ipc"
)
var intakeNow = time.Date(2026, 8, 1, 10, 0, 0, 0, time.UTC)
func intakeClock() time.Time { return intakeNow }
// failingAPI wraps the store adapter, failing the three intake writes on
// demand, so the "a failed write publishes nothing" invariant is testable.
type failingAPI struct {
ipc.CoreAPI
fail bool
}
func (f *failingAPI) WriteFact(ctx context.Context, req ipc.WriteFactReq) (int64, error) {
if f.fail {
return 0, errors.New("injected")
}
return f.CoreAPI.WriteFact(ctx, req)
}
func newIntakeTestAPI(t *testing.T) (ipc.CoreAPI, *event.Bus) {
t.Helper()
st := newTestStore(t)
bus := event.NewBus(32)
return newIntakeAPI(ipc.NewStoreAPI(st), bus, intakeClock), bus
}
func TestIntakeAPIWithoutBusIsTheBareAPI(t *testing.T) {
// The adoption invariant: with the journal off there is not even a
// decorator on the intake path, so production behaves exactly as before.
st := newTestStore(t)
bare := ipc.NewStoreAPI(st)
if got := newIntakeAPI(bare, nil, intakeClock); got != ipc.CoreAPI(bare) {
t.Errorf("newIntakeAPI with a nil bus returned a wrapper, want the bare API")
}
}
func TestNewEventBusOffWhenNegative(t *testing.T) {
if b := newEventBus(&config.Config{IntakeJournal: -1}); b != nil {
t.Error("intake_journal = -1 still built a bus")
}
if b := newEventBus(&config.Config{IntakeJournal: 4}); b == nil {
t.Error("intake_journal = 4 built no bus")
}
}
func TestIntakeJournalsAFactWrite(t *testing.T) {
api, bus := newIntakeTestAPI(t)
ctx := context.Background()
// The ambient path's shape: an env fact below full confidence, timestamped
// at the meeting's start rather than at notice time.
start := intakeNow.Add(2 * time.Hour)
if _, err := api.WriteFact(ctx, ipc.WriteFactReq{
Ts: start, Kind: "env", Key: "calendar_event_20260801_планёрка",
Value: "10:00-11:00 планёрка", Source: "ambient:notif", Confidence: 0.6,
}); err != nil {
t.Fatalf("WriteFact: %v", err)
}
got := bus.Recent(0)
if len(got) != 1 {
t.Fatalf("journal has %d entries, want 1", len(got))
}
e := got[0]
if e.Source != "ambient:notif" || e.Kind != event.KindFact {
t.Errorf("source/kind = %q/%q", e.Source, e.Kind)
}
if e.Title != "calendar_event_20260801_планёрка" {
t.Errorf("title = %q, want the fact key", e.Title)
}
if !e.OccurredAt.Equal(start) {
t.Errorf("occurred_at = %v, want the fact's Ts %v — the journal must not flatten intake to notice time", e.OccurredAt, start)
}
if e.Priority != event.PriorityLow {
t.Errorf("priority = %q, want %q for a sub-1.0 confidence read", e.Priority, event.PriorityLow)
}
}
func TestIntakeDoesNotJournalAFailedWrite(t *testing.T) {
st := newTestStore(t)
bus := event.NewBus(8)
api := newIntakeAPI(&failingAPI{CoreAPI: ipc.NewStoreAPI(st), fail: true}, bus, intakeClock)
if _, err := api.WriteFact(context.Background(), ipc.WriteFactReq{
Ts: intakeNow, Kind: "env", Key: "k", Value: "v", Source: "poll:zenmoney", Confidence: 1,
}); err == nil {
t.Fatal("expected the injected error")
}
if bus.Len() != 0 {
t.Errorf("journal has %d entries after a failed write, want 0 — an event reports something that happened", bus.Len())
}
}
func TestIntakeJournalsANoteAsTitlePlusBody(t *testing.T) {
api, bus := newIntakeTestAPI(t)
// The RSS shape: "headline\nsummary\nlink".
if _, err := api.WriteNote(context.Background(), intakeNow,
"Вышло ядро 6.19\nкраткое содержание\nhttps://example.org/a", nil, "rss:tech"); err != nil {
t.Fatalf("WriteNote: %v", err)
}
got := bus.Recent(1)
if len(got) != 1 {
t.Fatalf("journal has %d entries, want 1", len(got))
}
if got[0].Title != "Вышло ядро 6.19" {
t.Errorf("title = %q, want the headline", got[0].Title)
}
if got[0].Kind != event.KindNote {
t.Errorf("kind = %q, want %q", got[0].Kind, event.KindNote)
}
if got[0].Body == "" {
t.Error("body is empty, want the rest of the note")
}
}
func TestIntakeJournalsOnlyCreatedTasks(t *testing.T) {
api, bus := newIntakeTestAPI(t)
ctx := context.Background()
req := ipc.CaptureTaskReq{Text: "оплатить интернет", Source: "email:inbox", Status: "candidate", Ts: intakeNow}
if _, err := api.CaptureTask(ctx, req); err != nil {
t.Fatalf("CaptureTask: %v", err)
}
// Same text again: CaptureTask dedupes among live rows, and a re-read of a
// mailbox must not refill the journal.
resp, err := api.CaptureTask(ctx, req)
if err != nil {
t.Fatalf("CaptureTask (repeat): %v", err)
}
if resp.Created {
t.Fatal("store did not dedupe; the test cannot check what it means to")
}
if bus.Len() != 1 {
t.Errorf("journal has %d entries, want 1 — a deduped capture must not publish", bus.Len())
}
if got := bus.Recent(1)[0]; got.Kind != event.KindTask || got.Title != "оплатить интернет" {
t.Errorf("entry = %+v, want the captured task", got)
}
}
func TestIntakeEventsFnRendersNewestFirst(t *testing.T) {
api, bus := newIntakeTestAPI(t)
ctx := context.Background()
for _, key := range []string{"a", "b", "c"} {
if _, err := api.WriteFact(ctx, ipc.WriteFactReq{
Ts: intakeNow, Kind: "env", Key: key, Value: "1", Source: "poll:zenmoney", Confidence: 1,
}); err != nil {
t.Fatalf("WriteFact %s: %v", key, err)
}
}
fn := intakeEventsFn(bus)
got := fn(2)
if len(got) != 2 || got[0].Title != "c" || got[1].Title != "b" {
t.Errorf("intakeEventsFn(2) = %+v, want the two newest, newest first", got)
}
if intakeEventsFn(nil) != nil {
t.Error("intakeEventsFn(nil) returned a closure, want nil so daemonAPI reports an empty journal")
}
}
func TestDaemonAPIRecentEventsEmptyWithoutABus(t *testing.T) {
d := &daemonAPI{CoreAPI: ipc.UnimplementedCoreAPI{}}
got, err := d.RecentEvents(context.Background(), 10)
if err != nil {
t.Fatalf("RecentEvents with no journal errored: %v", err)
}
if len(got) != 0 {
t.Errorf("got %d events, want none", len(got))
}
}
+167
View File
@@ -0,0 +1,167 @@
// mavend/mail.go — core's half of the email reader (Vikunja #246,
// docs/plans/01-email-reader.md).
//
// The split: cmd/mavmaild holds the IMAP credential, connects to the mailbox
// and converts messages to plaintext; it hands each message to core over
// ipc.MethodIngestMail. Core runs the extraction on the resident model —
// llama-server lives in this process, spawned by the phraser — and writes what
// comes back through the one task intake seam.
//
// What this file may produce is exactly one thing: rows in `tasks` with status
// "candidate". No fact, no reminder, no note, no nudge, no calendar event. A
// 1.7B misreading a mail can therefore put a wrong line on a review page and
// nothing else; it can never make Maven speak, and it can never make her
// recite something out of an advert as true.
//
// Off unless configured twice over: no `email` block in mavend.json ⇒ the IPC
// method does not exist; no llama-server phraser ⇒ same. A reader pointed at a
// core that is not set up for mail gets ErrUnknownMethod rather than silence.
package main
import (
"context"
"fmt"
"log"
"time"
"github.com/kami/maven/internal/config"
"github.com/kami/maven/internal/email"
"github.com/kami/maven/internal/event"
"github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/phraser"
"github.com/kami/maven/internal/store"
)
// evidenceMaxChars — how much of the subject line is kept as a candidate's
// evidence. Enough to recognise the mail on /tasks, not enough to turn the task
// list into a copy of his mailbox.
const evidenceMaxChars = 160
// mailIntake — extraction + capture for one message at a time.
type mailIntake struct {
st *store.Store
ex *email.Extractor
timeout time.Duration
now func() time.Time
// bus — the unified intake journal (Vikunja #283). This path captures
// through the store directly rather than through ipc.CoreAPI, so the
// decorator in intake.go does not see it and the publish is explicit here.
// nil is a working no-op.
bus *event.Bus
}
// newMailIntake returns nil when mail ingestion must not be available, which is
// the default. Both preconditions are real:
//
// - no cfg.Email ⇒ not configured, and a capability is off unless configured;
// - no llama-server phraser ⇒ nothing to extract with. There is deliberately
// no keyword fallback: "the subject line became a task" is not extraction,
// it is a mailbox rendered as a to-do list, and it would fill the review
// page faster than he could clear it.
func newMailIntake(st *store.Store, phr phraser.Phraser, cfg *config.Config, bus *event.Bus) *mailIntake {
if cfg.Email == nil {
return nil
}
lp, ok := phr.(*phraser.LLMPhraser)
if !ok {
log.Printf("mail intake: configured but no llama-server phraser — mail ingestion disabled")
return nil
}
timeout := time.Duration(cfg.Email.Timeout)
if timeout <= 0 {
timeout = config.DefaultEmailTimeout
}
ex := email.NewExtractor(llmClientFor(lp, timeout), cfg.Email.MaxTasks, contextBlockFn(cfg, time.Now))
log.Printf("mail intake: enabled (max %d candidates per message, timeout %s)", cfg.Email.MaxTasks, timeout)
return &mailIntake{st: st, ex: ex, timeout: timeout, now: time.Now, bus: bus}
}
// ingest handles one ipc.MethodIngestMail call.
//
// Junk and empty messages are answered Skipped without touching the model — the
// reader's header filter is what keeps the resident model off newsletters.
//
// Every candidate is captured with Status "candidate", Source "email:<mailbox>"
// and the subject as Evidence. CaptureTask dedupes on normalised text among
// live rows, so a mailbox re-read after a restart produces Created=0 rather
// than a second copy of every task.
func (m *mailIntake) ingest(ctx context.Context, req ipc.IngestMailReq) (ipc.IngestMailResp, error) {
msg := email.Message{
UID: req.UID,
From: req.From,
Subject: req.Subject,
Date: req.Date,
Body: req.Body,
Junk: req.Junk,
}
if msg.Junk || (msg.Subject == "" && msg.Body == "") {
return ipc.IngestMailResp{Skipped: true}, nil
}
ctx, cancel := context.WithTimeout(ctx, m.timeout)
defer cancel()
cands, err := m.ex.Extract(ctx, msg)
if err != nil {
// The error from internal/email never carries mail text; keep it that way
// by not adding the subject here.
return ipc.IngestMailResp{}, fmt.Errorf("mail intake: uid %d: %w", req.UID, err)
}
if len(cands) == 0 {
return ipc.IngestMailResp{}, nil
}
source := email.SourcePrefix + req.Mailbox
evidence := truncateRunes(req.Subject, evidenceMaxChars)
now := m.now()
var resp ipc.IngestMailResp
for _, c := range cands {
t := store.Task{
CreatedTs: now,
Text: c.Text,
Source: source,
Evidence: evidence,
// The one status this path may ever write. Anything Maven derived from
// something she read is a suggestion until he confirms it on /tasks.
Status: store.TaskCandidate,
}
if due, ok := email.ParseDue(c.Due); ok {
t.Due = &due
}
id, created, err := m.st.CaptureTask(ctx, t)
if err != nil {
return resp, fmt.Errorf("mail intake: capture: %w", err)
}
resp.TaskIDs = append(resp.TaskIDs, id)
if created {
resp.Created++
// Only a row that was actually created. CaptureTask dedupes on
// normalised text among live rows, so a mailbox re-read after a
// restart must not refill the journal with tasks already in it.
m.bus.Publish(publishableTask(t, now), now)
}
}
// Counts only: the log line names the mailbox and the UID, never the subject,
// the sender or the task text. Reviewing a candidate is what /tasks is for.
log.Printf("mail intake: %s uid %d → %d candidate(s), %d new", source, req.UID, len(cands), resp.Created)
return resp, nil
}
// wireMailIntake installs the IPC hook, or leaves it nil so the method reports
// ErrUnknownMethod. Called on both startup paths (unlocked boot and passkey
// unlock) so mail behaves the same either way.
func wireMailIntake(srv *ipc.Server, st *store.Store, phr phraser.Phraser, cfg *config.Config, bus *event.Bus) {
mi := newMailIntake(st, phr, cfg, bus)
if mi == nil {
return
}
srv.IngestMailFn = mi.ingest
}
// truncateRunes cuts a string to n runes, marking the cut.
func truncateRunes(s string, n int) string {
r := []rune(s)
if len(r) <= n {
return s
}
return string(r[:n]) + "…"
}
+182
View File
@@ -0,0 +1,182 @@
package main
import (
"context"
"strings"
"testing"
"time"
"github.com/kami/maven/internal/config"
"github.com/kami/maven/internal/email"
"github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/llm"
"github.com/kami/maven/internal/store"
)
// mailLLM — a canned extraction reply.
type mailLLM struct {
reply string
calls int
}
func (m *mailLLM) Complete(_ context.Context, _ llm.Req) (string, error) {
m.calls++
return m.reply, nil
}
func newTestIntake(t *testing.T, reply string) (*mailIntake, *store.Store, *mailLLM) {
t.Helper()
st := newTestStore(t)
fake := &mailLLM{reply: reply}
return &mailIntake{
st: st,
ex: email.NewExtractor(fake, 0, nil),
timeout: 5 * time.Second,
now: func() time.Time { return time.Date(2026, 8, 1, 10, 0, 0, 0, time.UTC) },
}, st, fake
}
func ingestReq() ipc.IngestMailReq {
return ipc.IngestMailReq{
Mailbox: "INBOX", UID: 42,
From: "billing@isp.example",
Subject: "Счёт за интернет",
Body: "Оплатите счёт до 5 августа.",
}
}
// The one property that matters: a mail-derived task is a candidate, attributed
// to the mailbox, with the subject as reviewable evidence — and nothing else is
// written.
func TestIngestCapturesCandidates(t *testing.T) {
mi, st, _ := newTestIntake(t, `[{"text":"оплатить счёт за интернет","due":"2026-08-05"}]`)
resp, err := mi.ingest(context.Background(), ingestReq())
if err != nil {
t.Fatalf("ingest: %v", err)
}
if resp.Created != 1 || len(resp.TaskIDs) != 1 {
t.Fatalf("resp = %+v, want one created task", resp)
}
tasks, err := st.ListTasks(context.Background(), "")
if err != nil {
t.Fatalf("list: %v", err)
}
if len(tasks) != 1 {
t.Fatalf("got %d tasks, want 1", len(tasks))
}
got := tasks[0]
if got.Status != store.TaskCandidate {
t.Errorf("status = %q, want %q — mail may only produce candidates", got.Status, store.TaskCandidate)
}
if got.Source != "email:INBOX" {
t.Errorf("source = %q, want email:INBOX", got.Source)
}
if got.Evidence != "Счёт за интернет" {
t.Errorf("evidence = %q, want the subject line", got.Evidence)
}
if got.Due == nil || got.Due.Format("2006-01-02") != "2026-08-05" {
t.Errorf("due = %v, want 2026-08-05", got.Due)
}
// Nothing else may have been written: no reminder, no fact.
rem, err := st.ListReminders(context.Background(), 10)
if err != nil {
t.Fatalf("list reminders: %v", err)
}
if len(rem) != 0 {
t.Errorf("mail created %d reminders; a misread mail must never be able to fire", len(rem))
}
}
// Re-reading a mailbox must not grow the list — CaptureTask dedupes among live
// rows, and the intake relies on exactly that.
func TestIngestSameMailTwiceIsIdempotent(t *testing.T) {
mi, st, _ := newTestIntake(t, `[{"text":"оплатить счёт","due":""}]`)
if _, err := mi.ingest(context.Background(), ingestReq()); err != nil {
t.Fatalf("first ingest: %v", err)
}
resp, err := mi.ingest(context.Background(), ingestReq())
if err != nil {
t.Fatalf("second ingest: %v", err)
}
if resp.Created != 0 || len(resp.TaskIDs) != 1 {
t.Errorf("resp = %+v, want the existing row and Created=0", resp)
}
tasks, _ := st.ListTasks(context.Background(), "")
if len(tasks) != 1 {
t.Errorf("got %d tasks after two reads, want 1", len(tasks))
}
}
func TestIngestJunkSkipsTheModel(t *testing.T) {
mi, st, fake := newTestIntake(t, `[{"text":"купить со скидкой","due":""}]`)
req := ingestReq()
req.Junk = true
resp, err := mi.ingest(context.Background(), req)
if err != nil {
t.Fatalf("ingest: %v", err)
}
if !resp.Skipped || resp.Created != 0 {
t.Errorf("resp = %+v, want skipped", resp)
}
if fake.calls != 0 {
t.Errorf("model called %d times for junk, want 0", fake.calls)
}
if tasks, _ := st.ListTasks(context.Background(), ""); len(tasks) != 0 {
t.Errorf("junk produced %d tasks, want 0", len(tasks))
}
}
func TestIngestEmptyMessageSkipped(t *testing.T) {
mi, _, fake := newTestIntake(t, "[]")
resp, err := mi.ingest(context.Background(), ipc.IngestMailReq{Mailbox: "INBOX", UID: 1})
if err != nil || !resp.Skipped {
t.Fatalf("resp = %+v, err = %v; want skipped", resp, err)
}
if fake.calls != 0 {
t.Errorf("model called %d times for an empty message, want 0", fake.calls)
}
}
func TestIngestNoTasksWritesNothing(t *testing.T) {
mi, st, _ := newTestIntake(t, "[]")
resp, err := mi.ingest(context.Background(), ingestReq())
if err != nil {
t.Fatalf("ingest: %v", err)
}
if resp.Created != 0 || len(resp.TaskIDs) != 0 || resp.Skipped {
t.Errorf("resp = %+v, want nothing captured and not skipped", resp)
}
if tasks, _ := st.ListTasks(context.Background(), ""); len(tasks) != 0 {
t.Errorf("got %d tasks, want 0", len(tasks))
}
}
func TestIngestTruncatesEvidence(t *testing.T) {
mi, st, _ := newTestIntake(t, `[{"text":"дело","due":""}]`)
req := ingestReq()
req.Subject = strings.Repeat("щ", 400)
if _, err := mi.ingest(context.Background(), req); err != nil {
t.Fatalf("ingest: %v", err)
}
tasks, _ := st.ListTasks(context.Background(), "")
if len(tasks) != 1 {
t.Fatalf("got %d tasks, want 1", len(tasks))
}
if n := len([]rune(tasks[0].Evidence)); n > evidenceMaxChars+1 {
t.Errorf("evidence kept %d runes, want ≤ %d", n, evidenceMaxChars)
}
}
// Off unless configured: no email block ⇒ no intake, so the IPC method does not
// exist at all.
func TestNewMailIntakeOffWithoutConfig(t *testing.T) {
st := newTestStore(t)
if mi := newMailIntake(st, nil, &config.Config{}, nil); mi != nil {
t.Error("no email block must mean no mail intake")
}
// Configured but with a non-LLM phraser: still off — there is no fallback
// extraction, by design.
if mi := newMailIntake(st, nil, &config.Config{Email: &config.EmailConfig{}}, nil); mi != nil {
t.Error("without a llama-server phraser there is nothing to extract with")
}
}
+162 -18
View File
@@ -66,12 +66,19 @@ import (
var errLocked = errors.New("mavend: daemon locked — complete passkey assertion first")
// daemonLock tracks whether the daemon is in locked (pre-unlock) mode.
// In locked mode, all CoreAPI methods return errLocked. The unlock path
// replaces the CoreAPI with the real store adapter and flips the flag.
// daemonLock tracks whether the daemon is in locked (pre-unlock) mode, and
// owns the store handle the unlock path creates.
//
// The store matters here because of who runs when. In locked mode there is no
// store at boot; one is opened inside UnlockFn, on an IPC goroutine, minutes
// or days later. Shutdown runs on the main goroutine. Without a handoff the
// main goroutine has nothing to close, and store.Close is what re-encrypts
// the tmpfs working copy back over the ciphertext file — so a daemon that
// cold-started lost every write of that session, silently, on the next boot.
type daemonLock struct {
mu sync.Mutex
locked bool
st *store.Store
}
func newDaemonLock(locked bool) *daemonLock {
@@ -84,10 +91,25 @@ func (l *daemonLock) isLocked() bool {
return l.locked
}
func (l *daemonLock) unlock() {
// unlock flips the flag and takes ownership of the store opened by UnlockFn.
func (l *daemonLock) unlock(st *store.Store) {
l.mu.Lock()
defer l.mu.Unlock()
l.locked = false
l.st = st
}
// closeStore seals the store the unlock path opened, if any. Safe to call
// when the daemon never unlocked, and safe to call twice.
func (l *daemonLock) closeStore() error {
l.mu.Lock()
st := l.st
l.st = nil
l.mu.Unlock()
if st == nil {
return nil
}
return st.Close()
}
func main() {
@@ -155,6 +177,14 @@ func run(args []string) error {
return fmt.Errorf("open store: %w", err)
}
defer st.Close()
} else {
// Locked boot: the store does not exist yet. Seal whatever UnlockFn
// opened, at shutdown, on this goroutine.
defer func() {
if err := dl.closeStore(); err != nil {
log.Printf("mavend: seal store on shutdown: %v", err)
}
}()
}
// ----- daemon components (only wired when unlocked) -----
@@ -170,8 +200,20 @@ func run(args []string) error {
eco *ecosystemWiring
factWorker *factEnrichmentWorker
evalWorker *memoryEvalWorker // nil ⇒ memory evaluation off (the default)
feedWkr *feedWorker // nil ⇒ no feed is read (the default)
crawlWkr *crawlWorker // nil ⇒ no page is watched (the default)
)
// The unified intake journal (Vikunja #283). Built before anything else
// that holds a CoreAPI, because intakeAPI wraps that one interface and
// every intake path in the daemon reaches its sink through it. nil (the
// operator set intake_journal negative) means no decorator at all.
evBus := newEventBus(cfg)
// coreFor is what every in-process holder of a CoreAPI now takes, instead
// of a bare ipc.NewStoreAPI(st). Identical behaviour plus one published
// envelope per successful intake write.
coreFor := func() ipc.CoreAPI { return newIntakeAPI(ipc.NewStoreAPI(st), evBus, time.Now) }
if !locked {
rules = loop.DefaultRules()
gatherer = loop.NewGatherer(st, rules)
@@ -215,7 +257,7 @@ func run(args []string) error {
eco = wireEcosystem(cfg)
// voice
voiceW, err = wireVoice(cfg, ipc.NewStoreAPI(st), phr, st.VectorMemory(), st, eco)
voiceW, err = wireVoice(cfg, coreFor(), phr, st.VectorMemory(), st, eco)
if err != nil {
return fmt.Errorf("wire voice: %w", err)
}
@@ -265,17 +307,23 @@ func run(args []string) error {
tl = newTickLoop(st, gatherer, dispatcher, phr, rules, tickInterval, repeatInterval, autotuneInterval, cfg.Digest, routinesFromConfig(cfg.Routines), config.MorningRoutinesFromConfig(cfg.MorningRoutines), cfg.PatternProposals)
factWorker = newFactEnrichmentWorker(st, eco, time.Duration(cfg.FactEnrichmentInterval))
evalWorker = newMemoryEvalWorker(st, phr, cfg)
feedWkr = newFeedWorker(coreFor(), embedderOf(voiceW), cfg)
crawlWkr = newCrawlWorker(newCrawler(cfg), coreFor(), embedderOf(voiceW), cfg)
coreAPI = &daemonAPI{
CoreAPI: ipc.NewStoreAPI(st),
CoreAPI: coreFor(),
getTrace: tl.trace,
getMorningStatus: func(ctx context.Context) []ipc.MorningRoutineStatus { return tl.morningStatus(ctx, time.Now()) },
getDayPlan: func(ctx context.Context) ipc.DayPlan { return tl.dayPlan(ctx, time.Now()) },
getEvents: intakeEventsFn(evBus),
}
if voiceW != nil && voiceW.handler != nil {
api := coreAPI.(*daemonAPI)
api.chatFn = voiceW.handler.handleText
}
if voiceW != nil && voiceW.mcp != nil {
coreAPI.(*daemonAPI).getMCPServers = voiceW.mcp.status
}
} else {
// locked mode: no real store yet, so there's no meaningful CoreAPI to
// serve. srv.Check below is the actual guard — every CoreAPI call is
@@ -320,12 +368,35 @@ func run(args []string) error {
srv.StepUp = func(ctx context.Context) error { return passkeySess.Assert(ctx, auth.Scope{}) }
// WrapKeyFn — wraps the env key with a passkey credential public key and
// persists the wrapped blob. Only wired when the daemon has the key in
// memory (env key mode). Called by mavweb after passkey enrollment.
// Mail ingestion (Vikunja #246): the hook stays nil unless an email block is
// configured and there is a llama-server to extract with, in which case
// ipc.MethodIngestMail reports ErrUnknownMethod.
if !locked {
wireMailIntake(srv, st, phr, cfg, evBus)
wireModelSwap(srv, phr, cfg)
// Vision + the media blob store (Vikunja #252). Both stay dark without a
// media block; MethodDescribeImage answers ErrUnknownMethod then.
keeper := wireVision(ctx, srv, st, embedderOf(voiceW), cfg)
// The meeting recorder (Vikunja #253) shares that blob store and its
// retention loop. Off unless a capture block enables it, in which case
// all four capture methods answer ErrUnknownMethod.
wireCapture(srv, keeper, st, voiceW, phr, cfg)
// Voice identification (Vikunja #255). Enrolment plumbing only until a
// speaker-embedding model exists on disk; off entirely without a speaker
// block, so no wire path takes a voiceprint on a default box.
wireSpeaker(srv, st, cfg)
}
// WrapKeyFn — wraps the env key under the passkey PRF secret and persists
// the wrapped blob. Only wired when the daemon has the key in memory (env
// key mode). Called by mavweb after passkey enrollment.
//
// webauthn.WrapKey refuses anything that is not a 32-byte PRF output, so
// an authenticator without PRF support produces no wrapped file at all
// rather than a file that looks protected and is not.
if envKeyBytes != nil {
srv.WrapKeyFn = func(ctx context.Context, publicKey []byte) error {
blob, err := webauthn.WrapKey(envKeyBytes, publicKey)
srv.WrapKeyFn = func(ctx context.Context, secret []byte) error {
blob, err := webauthn.WrapKey(envKeyBytes, secret)
if err != nil {
return fmt.Errorf("wrap encryption key: %w", err)
}
@@ -341,20 +412,42 @@ func run(args []string) error {
}
}
// UnlockFn — cold-start unlock: unwraps the encryption key from the wrapped
// blob using the passkey credential public key, opens the store, wires all
// UnlockFn — cold-start unlock: unwraps the encryption key from the
// wrapped blob using the passkey PRF secret, opens the store, wires all
// daemon components, and replaces the locked API.
if locked {
srv.UnlockFn = func(ctx context.Context, publicKey []byte) error {
var unlockMu sync.Mutex
srv.UnlockFn = func(ctx context.Context, secret []byte) error {
// One unlock at a time, and never a second one. Without this a
// concurrent pair of Unlock calls would each open a store and
// wire a full daemon, and the loser's goroutines would run
// against a store nobody closes.
unlockMu.Lock()
defer unlockMu.Unlock()
if !dl.isLocked() {
return nil // already unlocked; the caller does not need to know
}
// The wire cannot authenticate its caller — the socket is
// same-uid — so the unlock path requires a passkey assertion
// that mavweb verified cryptographically first. Without this,
// MethodUnlock is reachable by anything on the box.
if !passkeySess.IsStepUp() {
return errors.New("unlock: no verified passkey assertion (assert first)")
}
wp := *wrappedKeyPath
blob, err := os.ReadFile(wp)
if err != nil {
return fmt.Errorf("read wrapped key: %w", err)
}
key, err := webauthn.UnwrapKey(blob, publicKey)
key, version, err := webauthn.UnwrapKey(blob, secret)
if err != nil {
return fmt.Errorf("unwrap key: %w", err)
}
if version == webauthn.BlobV1 {
log.Printf("SECURITY: %s was unwrapped from a %s blob. The wrapping key is derived from the credential PUBLIC key, which mavweb also writes to its passkeys.json — anyone holding both files can recover the database key with no authenticator. Re-enroll the passkey on an authenticator that supports the PRF extension to rewrite it as v2.", wp, version)
}
// Open the store with the unwrapped key.
st, err = store.OpenEncrypted(ctx, cfg.DBPath, cfg.DBTmpfs, key)
if err != nil {
@@ -400,7 +493,7 @@ func run(args []string) error {
eco = wireEcosystem(cfg)
voiceW, err = wireVoice(cfg, ipc.NewStoreAPI(st), phr, st.VectorMemory(), st, eco)
voiceW, err = wireVoice(cfg, coreFor(), phr, st.VectorMemory(), st, eco)
if err != nil {
return fmt.Errorf("wire voice: %w", err)
}
@@ -444,19 +537,30 @@ func run(args []string) error {
tl = newTickLoop(st, gatherer, dispatcher, phr, rules, tickInterval, repeatInterval, autotuneInterval, cfg.Digest, routinesFromConfig(cfg.Routines), config.MorningRoutinesFromConfig(cfg.MorningRoutines), cfg.PatternProposals)
factWorker = newFactEnrichmentWorker(st, eco, time.Duration(cfg.FactEnrichmentInterval))
evalWorker = newMemoryEvalWorker(st, phr, cfg)
feedWkr = newFeedWorker(coreFor(), embedderOf(voiceW), cfg)
crawlWkr = newCrawlWorker(newCrawler(cfg), coreFor(), embedderOf(voiceW), cfg)
// Swap the CoreAPI from the locked placeholder to the real store adapter.
newAPI := &daemonAPI{
CoreAPI: ipc.NewStoreAPI(st),
CoreAPI: coreFor(),
getTrace: tl.trace,
getMorningStatus: func(ctx context.Context) []ipc.MorningRoutineStatus { return tl.morningStatus(ctx, time.Now()) },
getDayPlan: func(ctx context.Context) ipc.DayPlan { return tl.dayPlan(ctx, time.Now()) },
getEvents: intakeEventsFn(evBus),
}
if voiceW != nil && voiceW.handler != nil {
newAPI.chatFn = voiceW.handler.handleText
}
srv.SetAPI(newAPI)
srv.Check = (&auth.Gate{Enrollment: auth.NewFloorEnrollment(), Session: passkeySess}).Check
wireMailIntake(srv, st, phr, cfg, evBus)
wireModelSwap(srv, phr, cfg)
keeper := wireVision(ctx, srv, st, embedderOf(voiceW), cfg)
wireCapture(srv, keeper, st, voiceW, phr, cfg)
// Voice identification (Vikunja #255). Enrolment plumbing only until a
// speaker-embedding model exists on disk; off entirely without a speaker
// block, so no wire path takes a voiceprint on a default box.
wireSpeaker(srv, st, cfg)
// Start voice server.
if voiceW != nil {
@@ -488,7 +592,26 @@ func run(args []string) error {
}()
}
dl.unlock()
// Start feed reading (nil unless configured).
if feedWkr != nil {
go func() {
feedWkr.run(ctx)
}()
}
// Start the watched-page crawls (nil unless configured).
if crawlWkr != nil {
go func() {
crawlWkr.run(ctx)
}()
}
// Keep MCP connections alive (nil unless configured).
if voiceW != nil && voiceW.mcp != nil {
go voiceW.mcp.run(ctx)
}
dl.unlock(st)
log.Printf("mavend: unlocked via passkey assertion")
return nil
}
@@ -533,6 +656,27 @@ func run(args []string) error {
evalWorker.run(ctx)
}()
}
if feedWkr != nil {
wg.Add(1)
go func() {
defer wg.Done()
feedWkr.run(ctx)
}()
}
if crawlWkr != nil {
wg.Add(1)
go func() {
defer wg.Done()
crawlWkr.run(ctx)
}()
}
if voiceW != nil && voiceW.mcp != nil {
wg.Add(1)
go func() {
defer wg.Done()
voiceW.mcp.run(ctx)
}()
}
}
<-ctx.Done()
+154
View File
@@ -0,0 +1,154 @@
package main
import (
"context"
"fmt"
"log"
"time"
"github.com/kami/maven/internal/config"
"github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/mcp"
"github.com/kami/maven/internal/store"
"github.com/kami/maven/internal/webfetch"
)
// mcpRefreshInterval — how often the manager re-dials a server that is down.
// The manager applies its own backoff on top, so this being short is cheap.
const mcpRefreshInterval = time.Minute
// mcpWiring — the MCP client, when the `mcp` block configures at least one
// enabled server. nil ⇒ nothing was configured, nothing is connected, and an
// allowlist row that happens to look like an MCP row refuses to run.
//
// It lives on the voice wiring because MCP tools ARE acts: they run through
// tool.Executor, the enabled allowlist and the confirm turn, which only exist
// on the voice/chat path. No voice surface ⇒ nothing that could call a tool.
type mcpWiring struct {
mgr *mcp.Manager
st *store.Store
}
// wireMCP builds the manager, connects, and proposes what it found. It never
// fails the daemon: a server that is unreachable at boot is logged and retried,
// because Maven starting is not contingent on someone else's process.
func wireMCP(cfg *config.Config, st *store.Store) *mcpWiring {
servers := cfg.MCPServers()
if len(servers) == 0 {
return nil
}
limits := webfetch.Config{}
if cfg.MCP != nil {
limits.AllowHosts = cfg.MCP.AllowHosts
limits.DenyHosts = cfg.MCP.DenyHosts
limits.MaxBytes = cfg.MCP.MaxBytes
limits.Timeout = time.Duration(cfg.MCP.Timeout)
}
mgr, err := mcp.NewManager(mcp.WebfetchDoor(limits), servers)
if err != nil {
// Validation already ran in config.validate, so this is a programming
// error rather than a config one. Still not fatal: MCP off is a working
// Maven.
log.Printf("mcp: not wired: %v", err)
return nil
}
w := &mcpWiring{mgr: mgr, st: st}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
mgr.Connect(ctx)
w.propose(ctx)
return w
}
// propose writes a 'proposed' allowlist row for every discovered tool. It does
// NOT enable anything: a configured server is a place Maven may look, not a
// capability she has. Kami enables what he wants on /tools, behind step-up,
// which is the same gate a shell tool goes through.
//
// Re-running on every boot is idempotent — ProposeMCPTool never touches an
// existing row, so a tool he disabled stays disabled and one he enabled keeps
// the cmd he enabled it with.
func (w *mcpWiring) propose(ctx context.Context) {
if w == nil {
return
}
now := time.Now()
fresh := 0
for _, t := range w.mgr.Tools() {
name := mcp.LocalName(t.Server, t.Name)
// No readOnlyHint ⇒ assume it mutates ⇒ the confirm turn. Being wrong
// in this direction only costs a question.
destructive := !t.ReadOnly
provenance := fmt.Sprintf("mcp %s/%s", t.Server, t.Name)
if t.Description != "" {
provenance += ": " + t.Description
}
ok, err := w.st.ProposeMCPTool(ctx, name, mcp.Scope(t.Server),
mcp.Cmd(t.Server, t.Name), destructive, provenance, now)
if err != nil {
log.Printf("mcp: propose %s: %v", name, err)
continue
}
if ok {
fresh++
}
}
if fresh > 0 {
log.Printf("mcp: %d new tool proposal(s) waiting on /tools", fresh)
}
}
// run re-dials downed servers and picks up tools that appeared, until ctx is
// canceled.
func (w *mcpWiring) run(ctx context.Context) {
if w == nil {
return
}
t := time.NewTicker(mcpRefreshInterval)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
w.mgr.Refresh(ctx)
w.propose(ctx)
}
}
}
// status maps the manager's view onto the wire type the web surface reads.
func (w *mcpWiring) status() []ipc.MCPServerStatus {
if w == nil {
return nil
}
in := w.mgr.Status()
out := make([]ipc.MCPServerStatus, 0, len(in))
for _, s := range in {
out = append(out, ipc.MCPServerStatus{
Name: s.Name,
Transport: s.Transport,
Target: s.Target,
Connected: s.Connected,
Server: s.Server,
Tools: s.Tools,
Err: s.Err,
})
}
return out
}
func (w *mcpWiring) close() {
if w == nil {
return
}
_ = w.mgr.Close()
}
// caller is the tool.MCPCaller the executor gets, or nil when MCP is off.
func (w *mcpWiring) caller() *mcp.Manager {
if w == nil {
return nil
}
return w.mgr
}
+77
View File
@@ -0,0 +1,77 @@
package main
import (
"context"
"strings"
"testing"
"github.com/kami/maven/internal/config"
)
func TestWireMCPOffWhenUnconfigured(t *testing.T) {
st := newTestStore(t)
for name, cfg := range map[string]*config.Config{
"no block": {},
"nothing enabled": {MCP: &config.MCPConfig{Servers: []config.MCPServerConfig{
{Name: "vikunja", URL: "http://192.168.1.104:9100/mcp"},
}}},
} {
t.Run(name, func(t *testing.T) {
if w := wireMCP(cfg, st); w != nil {
t.Fatal("MCP must be off unless a server is configured AND enabled")
}
})
}
// nil wiring must be safe to use everywhere it is reachable.
var w *mcpWiring
w.close()
w.propose(context.Background())
if w.status() != nil || w.caller() != nil {
t.Fatal("a nil wiring must report nothing")
}
}
// An unreachable server must not stop the daemon, must be reported as down, and
// must propose nothing.
func TestWireMCPUnreachableServerIsNotFatal(t *testing.T) {
st := newTestStore(t)
w := wireMCP(&config.Config{MCP: &config.MCPConfig{Servers: []config.MCPServerConfig{{
Name: "dead", Command: "/nonexistent/mcp-server", Enabled: true,
}}}}, st)
if w == nil {
t.Fatal("a configured server should still wire")
}
defer w.close()
st2 := w.status()
if len(st2) != 1 || st2[0].Connected || st2[0].Err == "" {
t.Fatalf("status = %+v", st2)
}
tools, err := st.ListTools(context.Background(), "")
if err != nil {
t.Fatal(err)
}
if len(tools) != 0 {
t.Fatalf("a server that never answered must propose nothing, got %+v", tools)
}
}
// A url server whose address is private is refused by webfetch unless that
// server sets allow_private. This is the guard the whole MCP path rides on, so
// it is asserted here too, at the wiring level.
func TestWireMCPPrivateURLRefusedWithoutAllowPrivate(t *testing.T) {
st := newTestStore(t)
w := wireMCP(&config.Config{MCP: &config.MCPConfig{Servers: []config.MCPServerConfig{{
Name: "lan", URL: "http://127.0.0.1:9100/mcp", Enabled: true,
}}}}, st)
if w == nil {
t.Fatal("should wire")
}
defer w.close()
s := w.status()[0]
if s.Connected {
t.Fatal("a loopback server must not connect without allow_private")
}
if !strings.Contains(s.Err, "private address") {
t.Fatalf("err = %q, want the private-address refusal", s.Err)
}
}
+1 -2
View File
@@ -16,7 +16,6 @@ import (
"time"
"github.com/kami/maven/internal/config"
"github.com/kami/maven/internal/llm"
"github.com/kami/maven/internal/memeval"
"github.com/kami/maven/internal/phraser"
"github.com/kami/maven/internal/store"
@@ -50,7 +49,7 @@ func newMemoryEvalWorker(st *store.Store, phr phraser.Phraser, cfg *config.Confi
}
// A generous per-request timeout: this is a long prompt to a Thinking model
// and nobody is waiting on the answer.
client := llm.New(lp.BaseURL(), 5*time.Minute)
client := llmClientFor(lp, 5*time.Minute)
ev := memeval.NewEvaluator(st, st, client, memeval.Config{
MaxItems: cfg.MemoryEval.MaxItems,
MinConfidence: cfg.MemoryEval.MinConfidence,
+113
View File
@@ -0,0 +1,113 @@
package main
import (
"context"
"fmt"
"log"
"path/filepath"
"time"
"github.com/kami/maven/internal/config"
"github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/llm"
"github.com/kami/maven/internal/phraser"
)
// Swapping the resident model while the daemon runs (Vikunja #250).
//
// Off unless configured: with no phraser.swap_models allowlist the two IPC
// methods are never wired, so they answer ErrUnknownMethod. When it is wired the
// swap method is AuthStepUp (internal/auth), which means an authed human surface
// only — there is no act, no intent and no timer that reaches it. The daemon
// never decides to change its own brain.
//
// The allowlist is exact-match against paths a human wrote in mavend.json. The
// request carries a path and llama-server is started with it as `-m`, so
// anything looser would turn "swap the model" into "load any file on my disk".
func wireModelSwap(srv *ipc.Server, phr phraser.Phraser, cfg *config.Config) {
if cfg.Phraser == nil || len(cfg.Phraser.SwapModels) == 0 {
return
}
lp, ok := phr.(*phraser.LLMPhraser)
if !ok {
log.Printf("model swap: phraser.swap_models is set but there is no llama-server phraser — swap disabled")
return
}
allowed := map[string]bool{}
for _, m := range cfg.Phraser.SwapModels {
allowed[filepath.Clean(m)] = true
}
// The configured model is always swappable back to, listed or not: the way
// out of a bad swap must not depend on remembering to allowlist the model
// you are already running.
allowed[filepath.Clean(cfg.Phraser.ModelPath)] = true
srv.SwapModelFn = func(ctx context.Context, req ipc.SwapModelReq) (ipc.SwapModelResp, error) {
path := filepath.Clean(req.ModelPath)
if !allowed[path] {
log.Printf("model swap: REFUSED %q — not in phraser.swap_models", req.ModelPath)
return ipc.SwapModelResp{}, fmt.Errorf("%w: %q is not in phraser.swap_models", ipc.ErrForbidden, req.ModelPath)
}
res, err := lp.Swap(ctx, phraser.SwapSpec{
ModelPath: path,
NGpuLayers: req.NGpuLayers,
NCtx: req.NCtx,
})
resp := ipc.SwapModelResp{
Model: res.Model,
ModelPath: res.ModelPath,
BaseURL: res.BaseURL,
RolledBack: res.RolledBack,
TookMs: res.Took.Milliseconds(),
}
if err != nil {
// A rolled-back swap is a failure that left a working daemon behind.
// Both halves matter to the caller, so the response is filled in even
// though the error is returned.
log.Printf("model swap: %v", err)
return resp, err
}
return resp, nil
}
srv.ModelStatusFn = func(ctx context.Context) (ipc.ModelStatusResp, error) {
path, ngl, nctx := lp.LiveModel()
base := lp.BaseURL()
resp := ipc.ModelStatusResp{
ModelPath: path,
BaseURL: base,
NGpuLayers: ngl,
NCtx: nctx,
Swappable: cfg.Phraser.SwapModels,
}
if base == "" {
resp.Model = llm.UnknownModel
return resp, nil
}
id, err := llm.ModelID(ctx, base)
if err != nil {
// Report the honest "I could not confirm it" rather than echoing the
// configured filename as if the server had said it.
resp.Model = llm.UnknownModel
return resp, nil
}
resp.Model = id
return resp, nil
}
log.Printf("model swap: enabled, %d allowlisted model(s) — step-up required", len(cfg.Phraser.SwapModels))
}
// llmClientFor builds a completion client on the phraser's llama-server and
// keeps it pointed at the right one across a model swap.
//
// Without the OnSwap registration every holder of a base URL — the LLM router,
// the replier, the mail extractor, the memory evaluator — would keep talking to
// the port of a server that no longer exists, and the daemon would degrade to
// the classifier permanently after the first swap. The client is re-pointed, not
// rebuilt, so nothing that holds it has to know a swap happened.
func llmClientFor(lp *phraser.LLMPhraser, timeout time.Duration) *llm.Client {
c := llm.New(lp.BaseURL(), timeout)
lp.OnSwap(func(base string) { c.SetBaseURL(base) })
return c
}
+798
View File
@@ -0,0 +1,798 @@
// mavend/simulator_test.go — the replayable full-system simulator
// (Vikunja #284, 20-07-2026-BACKLOG.md item 7).
//
// # What it is
//
// A scripted day, replayed through the real mavend code paths, with every
// boundary faked and the clock under the scenario's control. A scenario is a
// JSON file in testdata/scenarios; the harness reads it, builds a world, walks
// the steps in order, and asserts on what actually happened:
//
// what Maven SAID — the reply text of every utterance
// what was SENT — every delivery.Sendable the dispatcher emitted
// what ARRIVED — the unified intake journal from #283
// what TOOLS were called — the recorded requests against fake Praxis/Nexis/Hexis
// what did NOT happen — expect_no_send / expect_no_call, first-class
//
// The last one is the point. Maven's hard constraints are mostly negative —
// not a nag, not autonomous, nothing executed without confirmation — and a
// harness that can only assert on things that happened cannot test any of
// them. "Nothing was sent" is an assertion here, not an absence of one.
//
// # Determinism
//
// No time.Now() runs inside a replay. The scenario names a start instant, each
// step names a wall-clock offset from it, and the harness advances a fakeClock
// to that offset before running the step. Every clock reader in the world —
// the handler's `now`, the tick loop's `tick(ctx, now)`, the intake journal's
// publish stamp — is wired to that clock. Two runs of the same file produce
// the same transcript, and a scenario about 08:35 does not behave differently
// at 03:00 in CI.
//
// The tick is driven by the scenario, not by a ticker: tick() already takes
// `now` as an argument, so the only thing the daemon's ticker contributed was
// wall-clock timing, which is exactly what a replay must not have.
//
// # Why this shape and not a binary
//
// Vikunja #288 (golden-audio STT) deferred its tier-2 "audio → STT → router →
// phraser" scenarios to this task, and asked that they reuse a fixture format
// rather than inventing a third. A scenario here can name a WAV from
// cmd/mavsttd/testdata and the harness will feed it through the STT seam. As a
// test it runs under `make test` on every change, which a separate binary
// would not.
//
// # Production is untouched
//
// Every file this task adds is a _test.go file or testdata. There is no
// simulator in the daemon, no flag, no config key, and no code path that
// checks whether a simulation is running. The seams it uses — stt.Transcriber,
// tts.Synthesizer, router.Completer, delivery.Sink, ipc.CoreAPI, the
// event.Bus from #283 — all already existed for the production wiring.
package main
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"github.com/kami/maven/internal/audio"
"github.com/kami/maven/internal/config"
"github.com/kami/maven/internal/delivery"
"github.com/kami/maven/internal/dialogue"
"github.com/kami/maven/internal/event"
"github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/llm"
"github.com/kami/maven/internal/loop"
"github.com/kami/maven/internal/phraser"
"github.com/kami/maven/internal/router"
"github.com/kami/maven/internal/store"
"github.com/kami/maven/internal/tool"
"github.com/kami/maven/internal/voice"
)
// ---------------------------------------------------------------------------
// Scenario format
// ---------------------------------------------------------------------------
// scenario — one scripted day. schema_version matches the convention already
// set by testdata/system_safety_scenarios.json.
type scenario struct {
SchemaVersion int `json:"schema_version"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
// Start — the instant the day begins, RFC3339. Every step offset is
// relative to it, and nothing in the run reads a real clock.
Start string `json:"start"`
// Script — what the resident model answers. The world has no llama-server;
// see scriptedLLM for how an entry is chosen.
Script []scriptEntry `json:"script,omitempty"`
// Praxis / Nexus / Hexis — canned bodies for the ecosystem fakes. Absent ⇒
// that service is not wired at all, which is the default box.
Praxis string `json:"praxis_attention,omitempty"`
Nexus string `json:"nexus_resolve,omitempty"`
Hexis string `json:"hexis_capabilities,omitempty"`
Steps []step `json:"steps"`
}
// scriptEntry — one canned model answer. Match is a substring of the user
// message; the first entry whose Match is contained in it wins, and an entry
// with an empty Match is the catch-all.
//
// Route and Reply are separate because the same model serves both contracts
// (CLAUDE.md, "LLM output contract"): a grammar-constrained call is a routing
// call and gets Route, an unconstrained one is a phrasing call and gets Reply.
type scriptEntry struct {
Match string `json:"match"`
Route string `json:"route,omitempty"`
Reply string `json:"reply,omitempty"`
}
// step — one scripted moment. At is "HH:MM" or "HH:MM:SS", interpreted in the
// start instant's location; the clock is advanced to it before the step runs.
//
// A step does exactly one thing (say / audio / signal / fact / tick / arrive)
// and then asserts. Assertions are evaluated against everything recorded since
// the run began, except expect_no_send and expect_no_call, which are scoped to
// this step — "nothing was sent because of THIS" is the useful question.
type step struct {
At string `json:"at"`
Note string `json:"note,omitempty"`
// --- stimuli (at most one per step) ---
// Say — an utterance, as text, through the same runTurn the IPC chat path
// uses.
Say string `json:"say,omitempty"`
// Audio — a WAV under cmd/mavsttd/testdata, fed through the STT seam. This
// is #288's deferred tier 2. The harness uses the deterministic stt stub
// unless a real transcriber is available, so the assertion a scenario can
// make about an audio step is about the PIPELINE, not about whisper's
// accuracy — that is what cmd/mavsttd/golden_test.go is for.
Audio string `json:"audio,omitempty"`
// Signal — a presence/world fact arriving from a poller or /api/signal.
Signal *signalStep `json:"signal,omitempty"`
// Arrive — an intake write from a module: an ambient notification, a feed
// item, a mail candidate. Goes through the same decorated ipc.CoreAPI the
// daemon gives those callers, so it lands in the journal exactly as it
// would in production.
Arrive *arriveStep `json:"arrive,omitempty"`
// Tick — run one iteration of the proactive loop at this instant.
Tick bool `json:"tick,omitempty"`
// Fault — make every ecosystem fake answer with this HTTP status from now
// on. The degraded-mode lever; ClearFault puts them back.
Fault int `json:"fault,omitempty"`
ClearFault bool `json:"clear_fault,omitempty"`
// --- assertions ---
ExpectReply []string `json:"expect_reply_contains,omitempty"`
ExpectNotReply []string `json:"expect_reply_lacks,omitempty"`
ExpectSent []string `json:"expect_sent_contains,omitempty"`
ExpectNoSend bool `json:"expect_no_send,omitempty"`
ExpectCalled []string `json:"expect_called,omitempty"`
ExpectNotCalled []string `json:"expect_not_called,omitempty"`
ExpectEvents []string `json:"expect_events,omitempty"`
ExpectNoEvents bool `json:"expect_no_events,omitempty"`
}
type signalStep struct {
Key string `json:"key"`
Value string `json:"value"`
Source string `json:"source"`
Kind string `json:"kind,omitempty"`
}
type arriveStep struct {
// Note / Fact / Task — exactly one. Each mirrors the intake seam its real
// caller uses.
Note *arriveNote `json:"note,omitempty"`
Fact *signalStep `json:"fact,omitempty"`
Task *arriveTask `json:"task,omitempty"`
AsOf string `json:"as_of,omitempty"` // "HH:MM" — OccurredAt, when it differs from the step time
Source string `json:"source"`
}
type arriveNote struct {
Text string `json:"text"`
}
type arriveTask struct {
Text string `json:"text"`
Evidence string `json:"evidence,omitempty"`
Status string `json:"status,omitempty"`
}
// ---------------------------------------------------------------------------
// The world
// ---------------------------------------------------------------------------
// simWorld — every faked boundary plus the real components between them.
type simWorld struct {
t *testing.T
clock *fakeClock
loc *time.Location
start time.Time
store *store.Store
api ipc.CoreAPI // the intake-decorated adapter, same as the daemon builds
bus *event.Bus
handler *reactiveHandler
tick *tickLoop
sink *recordingSink
llm *scriptedLLM
praxis *fakeServer
nexus *fakeServer
hexis *fakeServer
// transcript — everything that happened, in order. Printed on failure so a
// broken scenario is diagnosable without a debugger.
transcript []string
replies []string
}
// recordingSink captures every send, mutex-guarded (the tick loop dispatches
// from its own goroutine in production and the race detector is on here).
type recordingSink struct {
mu sync.Mutex
sends []delivery.Sendable
}
func (s *recordingSink) Send(_ context.Context, d delivery.Sendable) error {
s.mu.Lock()
defer s.mu.Unlock()
s.sends = append(s.sends, d)
return nil
}
func (s *recordingSink) all() []delivery.Sendable {
s.mu.Lock()
defer s.mu.Unlock()
out := make([]delivery.Sendable, len(s.sends))
copy(out, s.sends)
return out
}
func (s *recordingSink) count() int {
s.mu.Lock()
defer s.mu.Unlock()
return len(s.sends)
}
// scriptedLLM stands in for llama-server on BOTH contracts the resident model
// serves: grammar-constrained routing and unconstrained phrasing.
//
// It is not a stub that ignores its input — a scenario that scripts an answer
// for "что я пропустил" and gets asked something else must fail, not silently
// return the wrong intent. An unmatched call returns an error, and the router
// then falls through to the classifier cascade exactly as it does in
// production when llama-server is unreachable. That fall-through is itself
// worth exercising: it is the failure floor CLAUDE.md refuses to let rot.
type scriptedLLM struct {
mu sync.Mutex
entries []scriptEntry
calls []llm.Req
}
func (s *scriptedLLM) Complete(_ context.Context, r llm.Req) (string, error) {
s.mu.Lock()
defer s.mu.Unlock()
s.calls = append(s.calls, r)
routing := r.Grammar != ""
for _, e := range s.entries {
if e.Match != "" && !strings.Contains(strings.ToLower(r.User), strings.ToLower(e.Match)) {
continue
}
if routing && e.Route != "" {
return e.Route, nil
}
if !routing && e.Reply != "" {
return e.Reply, nil
}
}
return "", fmt.Errorf("simulator: no scripted %s answer for %q",
map[bool]string{true: "route", false: "reply"}[routing], truncateRunes(r.User, 60))
}
// ---------------------------------------------------------------------------
// Building the world
// ---------------------------------------------------------------------------
func newSimWorld(t *testing.T, sc scenario) *simWorld {
t.Helper()
start, err := time.Parse(time.RFC3339, sc.Start)
if err != nil {
t.Fatalf("scenario %q: bad start %q: %v", sc.Name, sc.Start, err)
}
clock := newFakeClock(start)
st := newTestStore(t)
bus := event.NewBus(512)
// The same decorator the daemon wires, on the same clock: intake in a
// replay is journalled exactly as it is in production.
api := newIntakeAPI(ipc.NewStoreAPI(st), bus, clock.Now)
sink := &recordingSink{}
rules := loop.DefaultRules()
gatherer := loop.NewGatherer(st, rules)
dispatcher := delivery.NewDispatcher(delivery.Config{
Voice: sink, Ntfy: sink, Telegram: sink, Nudges: st, Reminders: st,
})
tl := newTickLoop(st, gatherer, dispatcher, phraser.NewStub(), rules,
time.Minute, 5*time.Minute, 0, nil, nil, nil, nil)
scripted := &scriptedLLM{entries: sc.Script}
w := &simWorld{
t: t, clock: clock, loc: start.Location(), start: start,
store: st, api: api, bus: bus, tick: tl, sink: sink, llm: scripted,
}
// Ecosystem fakes, wired only when the scenario supplies a body — a box
// with no praxis block has no praxis client, and a scenario must be able to
// reproduce that.
eco := &ecosystemWiring{}
if sc.Praxis != "" {
w.praxis = newFakePraxis(t, sc.Praxis)
eco.praxis = newPraxisClient(w.praxis.URL)
}
if sc.Nexus != "" {
w.nexus = newFakeNexus(t, sc.Nexus)
}
if sc.Hexis != "" {
w.hexis = newFakeHexis(t, sc.Hexis, fixtureHexisExecuted("exec_1", "completed"))
}
// The router: the same cascade the daemon builds — stage-0 grammars, the
// LLM router on the scripted model, the classifier underneath. Keeping the
// classifier in is deliberate; it is the failure floor, and a scenario that
// scripts no route for an utterance exercises it.
emb := router.NewHashEmbedder(1024)
matcher := tool.NewMatcher(nil)
rtr := buildRouter(emb, matcher, config.DefaultRouterThreshold, router.NewLLMRouter(scripted))
w.handler = &reactiveHandler{
stt: simTranscriber{},
tts: simSynthesizer{},
router: rtr,
embedder: emb,
api: api,
matcher: matcher,
phraser: phraser.NewStub(),
replier: newLLMReplier(scripted, nil),
now: clock.Now,
memStore: st.VectorMemory(),
dataStore: st,
queryMinScore: config.DefaultQueryMinScore,
queryMinMargin: config.DefaultQueryMinMargin,
timeParser: router.StubDateTimeParser{},
dialogueSessions: dialogue.NewSessionStore(time.Hour),
clarifyStore: dialogue.NewClarifyStore(time.Hour),
clarifyMaxAttempts: dialogue.DefaultMaxAttempts,
ecosystem: eco,
}
return w
}
// simTranscriber — the STT seam. Deterministic by construction: it returns the
// text the harness parked for this step, so the pipeline under test is
// "audio arrives → a turn runs", not "whisper heard correctly". Transcription
// accuracy is cmd/mavsttd/golden_test.go's job (#288 tier 1), and duplicating
// it here would make every scenario depend on a 500 MB model.
type simTranscriber struct{ text string }
func (s simTranscriber) Transcribe(_ context.Context, _ audio.Audio) (string, float64, error) {
return s.text, 1.0, nil
}
// simSynthesizer — the TTS seam. A scenario asserts on what Maven SAID, which
// is the reply text; the waveform is not the artefact under test.
type simSynthesizer struct{}
func (simSynthesizer) Synthesize(_ context.Context, _ string) (audio.Audio, error) {
return audio.Audio{Format: audio.PCM16kMono}, nil
}
// ---------------------------------------------------------------------------
// Running
// ---------------------------------------------------------------------------
func (w *simWorld) logf(format string, args ...any) {
w.transcript = append(w.transcript,
fmt.Sprintf("%s %s", w.clock.Now().In(w.loc).Format("15:04:05"), fmt.Sprintf(format, args...)))
}
// dump prints the whole transcript. Called on any failure — a scenario that
// broke on step 7 is unreadable without the six steps before it.
func (w *simWorld) dump() {
w.t.Logf("--- replay transcript ---\n%s", strings.Join(w.transcript, "\n"))
}
// advanceTo moves the clock to the step's offset. Time only ever moves
// FORWARD: a scenario with steps out of order is a bug in the scenario, and
// silently reordering it would hide the bug.
func (w *simWorld) advanceTo(at string) {
w.t.Helper()
if at == "" {
return
}
target := w.timeOf(at)
now := w.clock.Now()
if target.Before(now) {
w.t.Fatalf("step at %s goes backwards from %s — scenario steps must be in order",
at, now.In(w.loc).Format("15:04:05"))
}
w.clock.Advance(target.Sub(now))
}
// timeOf resolves an "HH:MM" or "HH:MM:SS" step offset against the scenario's
// start day and location.
func (w *simWorld) timeOf(at string) time.Time {
w.t.Helper()
layout := "15:04"
if strings.Count(at, ":") == 2 {
layout = "15:04:05"
}
hm, err := time.Parse(layout, at)
if err != nil {
w.t.Fatalf("bad step time %q: %v", at, err)
}
return time.Date(w.start.Year(), w.start.Month(), w.start.Day(),
hm.Hour(), hm.Minute(), hm.Second(), 0, w.loc)
}
func (w *simWorld) run(sc scenario) {
ctx := context.Background()
for i, s := range sc.Steps {
w.advanceTo(s.At)
if s.Note != "" {
w.logf("# %s", s.Note)
}
sendsBefore := w.sink.count()
callsBefore := w.callCount()
eventsBefore := w.bus.Len()
w.stimulate(ctx, s)
w.assert(i, s, sendsBefore, callsBefore, eventsBefore)
}
}
func (w *simWorld) stimulate(ctx context.Context, s step) {
if s.Fault != 0 || s.ClearFault {
for _, fs := range []*fakeServer{w.praxis, w.nexus, w.hexis} {
if fs != nil {
fs.SetFault(s.Fault)
}
}
w.logf("fault=%d on every ecosystem fake", s.Fault)
}
switch {
case s.Say != "":
reply := w.handler.runTurn(ctx, s.Say)
w.replies = append(w.replies, reply)
w.logf("он: %s", s.Say)
w.logf("она: %s", reply)
case s.Audio != "":
text := w.audioText(s.Audio)
// Swap in a transcriber parked with this step's text, then run the same
// push-to-talk entry point the voice client calls.
w.handler.stt = simTranscriber{text: text}
resp, err := w.handler.HandlePushToTalk(ctx, voicePTT(), 0)
if err != nil {
w.t.Fatalf("push-to-talk on %s: %v", s.Audio, err)
}
w.replies = append(w.replies, resp.ReplyText)
w.logf("[wav %s → %q]", filepath.Base(s.Audio), text)
w.logf("она: %s", resp.ReplyText)
case s.Signal != nil:
w.write(ctx, *s.Signal, w.clock.Now())
w.logf("сигнал: %s=%s (%s)", s.Signal.Key, s.Signal.Value, s.Signal.Source)
case s.Arrive != nil:
w.arrive(ctx, *s.Arrive)
case s.Tick:
w.tick.tick(ctx, w.clock.Now())
w.logf("tick")
}
}
func (w *simWorld) write(ctx context.Context, sig signalStep, ts time.Time) {
w.t.Helper()
kind := sig.Kind
if kind == "" {
kind = "env"
}
if _, err := w.api.WriteFact(ctx, ipc.WriteFactReq{
Ts: ts, Kind: kind, Key: sig.Key, Value: sig.Value, Source: sig.Source, Confidence: 1.0,
}); err != nil {
w.t.Fatalf("write fact %s: %v", sig.Key, err)
}
}
func (w *simWorld) arrive(ctx context.Context, a arriveStep) {
w.t.Helper()
// AsOf is when the thing HAPPENED, which for a feed item or a relayed
// notification is usually earlier than when Maven heard about it. It does
// not move the clock — only the timestamp on the row and the envelope.
ts := w.clock.Now()
if a.AsOf != "" {
ts = w.timeOf(a.AsOf)
}
switch {
case a.Fact != nil:
f := *a.Fact
if f.Source == "" {
f.Source = a.Source
}
w.write(ctx, f, ts)
w.logf("пришло: факт %s=%s (%s)", f.Key, f.Value, f.Source)
case a.Note != nil:
if _, err := w.api.WriteNote(ctx, ts, a.Note.Text, nil, a.Source); err != nil {
w.t.Fatalf("write note from %s: %v", a.Source, err)
}
w.logf("пришло: заметка от %s — %s", a.Source, truncateRunes(a.Note.Text, 60))
case a.Task != nil:
status := a.Task.Status
if status == "" {
status = store.TaskCandidate
}
if _, err := w.api.CaptureTask(ctx, ipc.CaptureTaskReq{
Text: a.Task.Text, Source: a.Source, Evidence: a.Task.Evidence, Status: status, Ts: ts,
}); err != nil {
w.t.Fatalf("capture task from %s: %v", a.Source, err)
}
w.logf("пришло: задача от %s — %s", a.Source, a.Task.Text)
default:
w.t.Fatalf("arrive step from %s carries nothing", a.Source)
}
}
// audioText resolves a scenario's WAV reference to the text the fixture is
// known to contain, by reading cmd/mavsttd's golden manifest (#288's format,
// reused rather than duplicated). An unknown reference fails the scenario
// rather than quietly transcribing to "".
func (w *simWorld) audioText(ref string) string {
w.t.Helper()
manifest := filepath.Join("..", "mavsttd", "testdata", "golden_v1.json")
raw, err := os.ReadFile(manifest)
if err != nil {
w.t.Fatalf("audio step %q: reading %s: %v", ref, manifest, err)
}
var m struct {
Cases []struct {
Name string `json:"name"`
WAV string `json:"wav"`
Text string `json:"text"`
} `json:"cases"`
}
if err := json.Unmarshal(raw, &m); err != nil {
w.t.Fatalf("audio step %q: parsing %s: %v", ref, manifest, err)
}
for _, c := range m.Cases {
if c.Name == ref || c.WAV == ref {
return c.Text
}
}
w.t.Fatalf("audio step %q: no such case in %s", ref, manifest)
return ""
}
func voicePTT() voice.PushToTalkReq {
return voice.PushToTalkReq{Audio: audio.Audio{Format: audio.PCM16kMono}}
}
// callCount — how many requests every wired ecosystem fake has seen.
func (w *simWorld) callCount() int {
n := 0
for _, fs := range []*fakeServer{w.praxis, w.nexus, w.hexis} {
if fs != nil {
n += len(fs.Requests())
}
}
return n
}
func (w *simWorld) callPaths() []string {
var out []string
for _, fs := range []*fakeServer{w.praxis, w.nexus, w.hexis} {
if fs == nil {
continue
}
for _, r := range fs.Requests() {
out = append(out, r.Method+" "+r.Path)
}
}
return out
}
// ---------------------------------------------------------------------------
// Assertions
// ---------------------------------------------------------------------------
func (w *simWorld) assert(i int, s step, sendsBefore, callsBefore, eventsBefore int) {
w.t.Helper()
where := fmt.Sprintf("step %d (%s)", i+1, s.At)
if s.Note != "" {
where += " " + s.Note
}
fail := func(format string, args ...any) {
w.dump()
w.t.Errorf("%s: %s", where, fmt.Sprintf(format, args...))
}
lastReply := ""
if len(w.replies) > 0 {
lastReply = w.replies[len(w.replies)-1]
}
for _, want := range s.ExpectReply {
if !containsFold(lastReply, want) {
fail("reply %q does not contain %q", lastReply, want)
}
}
for _, unwanted := range s.ExpectNotReply {
if containsFold(lastReply, unwanted) {
fail("reply %q contains %q and must not", lastReply, unwanted)
}
}
sent := w.sink.all()
for _, want := range s.ExpectSent {
if !anyContains(sendableTexts(sent), want) {
fail("nothing sent mentions %q; sent so far: %v", want, sendableTexts(sent))
}
}
// Scoped to this step on purpose: "nothing was sent BECAUSE OF THIS" is the
// question a not-a-nag constraint asks.
if s.ExpectNoSend && len(sent) > sendsBefore {
fail("expected nothing to be sent, got %v", sendableTexts(sent[sendsBefore:]))
}
paths := w.callPaths()
for _, want := range s.ExpectCalled {
if !anyContains(paths, want) {
fail("no ecosystem call matches %q; calls so far: %v", want, paths)
}
}
for _, unwanted := range s.ExpectNotCalled {
if anyContains(paths[callsBefore:], unwanted) {
fail("an ecosystem call matched %q and must not have: %v", unwanted, paths[callsBefore:])
}
}
evs := w.bus.Recent(0)
for _, want := range s.ExpectEvents {
if !anyContains(eventLines(evs), want) {
fail("no intake event matches %q; journal: %v", want, eventLines(evs))
}
}
if s.ExpectNoEvents && w.bus.Len() > eventsBefore {
fail("expected nothing to arrive, journal grew to %d", w.bus.Len())
}
}
func sendableTexts(sends []delivery.Sendable) []string {
out := make([]string, 0, len(sends))
for _, s := range sends {
out = append(out, fmt.Sprintf("[%s] %s", s.RuleName, s.Body))
}
return out
}
func eventLines(evs []event.Event) []string {
out := make([]string, 0, len(evs))
for _, e := range evs {
out = append(out, fmt.Sprintf("%s/%s %s %s", e.Source, e.Kind, e.Title, e.Body))
}
return out
}
func containsFold(hay, needle string) bool {
return strings.Contains(strings.ToLower(hay), strings.ToLower(needle))
}
func anyContains(hay []string, needle string) bool {
for _, h := range hay {
if containsFold(h, needle) {
return true
}
}
return false
}
// ---------------------------------------------------------------------------
// The test
// ---------------------------------------------------------------------------
const scenarioDir = "testdata/scenarios"
// TestSimulatorScenarios replays every scenario file. Adding a scenario is
// adding a JSON file — no Go change, which is the property that makes this
// cheap enough to actually use.
func TestSimulatorScenarios(t *testing.T) {
entries, err := os.ReadDir(scenarioDir)
if err != nil {
t.Fatalf("reading %s: %v", scenarioDir, err)
}
var ran int
for _, ent := range entries {
if ent.IsDir() || !strings.HasSuffix(ent.Name(), ".json") {
continue
}
ran++
name := strings.TrimSuffix(ent.Name(), ".json")
t.Run(name, func(t *testing.T) {
sc := loadScenario(t, filepath.Join(scenarioDir, ent.Name()))
w := newSimWorld(t, sc)
w.run(sc)
if testing.Verbose() {
w.dump()
}
})
}
if ran == 0 {
t.Fatalf("no scenarios in %s — the harness would pass vacuously", scenarioDir)
}
}
func loadScenario(t *testing.T, path string) scenario {
t.Helper()
raw, err := os.ReadFile(path)
if err != nil {
t.Fatalf("reading %s: %v", path, err)
}
var sc scenario
dec := json.NewDecoder(strings.NewReader(string(raw)))
dec.DisallowUnknownFields() // a typo'd assertion key must fail, not be ignored
if err := dec.Decode(&sc); err != nil {
t.Fatalf("parsing %s: %v", path, err)
}
if sc.SchemaVersion != 1 {
t.Fatalf("%s: schema_version = %d, want 1", path, sc.SchemaVersion)
}
if sc.Name == "" || sc.Start == "" || len(sc.Steps) == 0 {
t.Fatalf("%s: a scenario needs a name, a start and at least one step", path)
}
return sc
}
// TestSimulatorIsDeterministic replays one scenario twice and requires an
// identical transcript. This is the property the whole task rests on: if a
// time.Now() creeps into a replayed path, two runs diverge and this fails.
func TestSimulatorIsDeterministic(t *testing.T) {
path := filepath.Join(scenarioDir, "morning_missed.json")
sc := loadScenario(t, path)
transcriptOf := func() string {
w := newSimWorld(t, sc)
w.run(sc)
return strings.Join(w.transcript, "\n")
}
first := transcriptOf()
second := transcriptOf()
if first != second {
t.Errorf("two replays of the same scenario diverged:\n--- first ---\n%s\n--- second ---\n%s", first, second)
}
// And the transcript's own timestamps must be the scenario's, not today's.
if strings.Contains(first, time.Now().Format("15:04")) && !strings.Contains(sc.Start, time.Now().Format("15:04")) {
t.Error("transcript carries the wall clock — something in the replay path read time.Now()")
}
}
// TestSimulatorRefusesBackwardsSteps guards the one scenario-authoring mistake
// that would silently produce a meaningless run.
func TestSimulatorRefusesBackwardsSteps(t *testing.T) {
// Not table-driven through run() because advanceTo calls t.Fatalf; this
// checks the ordering arithmetic directly.
sc := scenario{SchemaVersion: 1, Name: "x", Start: "2026-08-01T08:30:00+03:00",
Steps: []step{{At: "09:00"}}}
w := newSimWorld(t, sc)
w.advanceTo("09:00")
if got := w.clock.Now().In(w.loc).Format("15:04"); got != "09:00" {
t.Fatalf("clock at %s after advancing to 09:00", got)
}
w.advanceTo("09:30")
if got := w.clock.Now().In(w.loc).Format("15:04"); got != "09:30" {
t.Fatalf("clock at %s after advancing to 09:30", got)
}
}
+146
View File
@@ -0,0 +1,146 @@
// mavend/speaker.go — core's half of voice identification (Vikunja #255,
// docs/plans/10-speaker-recognition.md).
//
// # What is actually wired here, and what is not
//
// The enrolment plumbing is real: profiles are stored, listed and deleted, and
// the wire methods exist as soon as a speaker block is configured. The
// recognising half is NOT, and cannot be on this box, because there is no
// speaker-embedding model on disk — no ECAPA, no x-vector, no titanet, no
// wespeaker, nothing in /mnt/hdd1/llms but text ggufs. Until one is downloaded,
// newSpeakerEmbedder returns nil, internal/speaker falls back to
// speaker.Disabled, and every Identify answers ErrDisabled. The daemon logs
// which half is off at startup rather than pretending.
//
// This is deliberately not papered over with a hand-rolled MFCC floor. A
// biometric that is confidently wrong writes false claims about named people
// into his memory, and that is worse than a capability that is honestly absent.
//
// # Off unless configured
//
// No speaker block, or one without enabled, ⇒ the three methods do not exist and
// answer ErrUnknownMethod. On an unconfigured box there is no wire path that
// takes a voiceprint at all.
//
// # The refused design step
//
// The plan asks for unknown speakers to be enrolled on first interaction. That
// is refused in internal/speaker/enroll.go and there is no handler for it here:
// no request shape in the protocol enrols whoever just spoke. Taking a biometric
// of a guest who walked past the microphone is not something this daemon does.
package main
import (
"context"
"errors"
"log"
"github.com/kami/maven/internal/config"
"github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/speaker"
"github.com/kami/maven/internal/store"
)
// speakerWiring holds the recognizer behind the three IPC handlers.
type speakerWiring struct {
rec *speaker.Recognizer
}
// newSpeakerEmbedder loads the speaker-embedding model named by the config.
//
// It always returns nil today. The seam exists so that wiring a real model is a
// change to this one function and nothing else: give it a loader, and Identify
// starts working with no change to the store, the protocol, the auth table or
// the handlers. See the plan document for what to download.
func newSpeakerEmbedder(cfg *config.SpeakerConfig) speaker.Embedder {
if cfg == nil || cfg.ModelPath == "" {
return nil
}
log.Printf("speaker: model_path %q is configured but no embedding backend is built yet; "+
"enrolment and deletion work, recognition does not (Vikunja #255)", cfg.ModelPath)
return nil
}
// newSpeakerWiring builds the recognizer, or nil when the capability is off.
func newSpeakerWiring(st *store.Store, cfg *config.Config) *speakerWiring {
if cfg == nil || cfg.Speaker == nil || !cfg.Speaker.Enabled {
return nil
}
if st == nil {
log.Print("speaker: enabled but there is no store to keep profiles in; staying off")
return nil
}
rec, err := speaker.New(newSpeakerEmbedder(cfg.Speaker), st.VectorMemory(), speaker.Config{
Threshold: cfg.Speaker.Threshold,
MinSeconds: cfg.Speaker.MinSeconds,
})
if err != nil {
log.Printf("speaker: %v; staying off", err)
return nil
}
if rec.Enabled() {
log.Printf("speaker: recognition on, threshold %.2f", rec.Threshold())
} else {
log.Print("speaker: enrolment on, recognition BLOCKED — no speaker-embedding model " +
"on this box (see docs/plans/10-speaker-recognition.md)")
}
return &speakerWiring{rec: rec}
}
func (w *speakerWiring) enroll(ctx context.Context, req ipc.EnrollSpeakerReq) (ipc.EnrollSpeakerResp, error) {
p, err := w.rec.Enroll(ctx, req.ID, req.Name, req.Samples)
if err != nil {
return ipc.EnrollSpeakerResp{}, speakerErr(err)
}
return ipc.EnrollSpeakerResp{Speaker: toWireSpeaker(p)}, nil
}
func (w *speakerWiring) list(ctx context.Context) (ipc.ListSpeakersResp, error) {
ps, err := w.rec.List(ctx)
if err != nil {
return ipc.ListSpeakersResp{}, speakerErr(err)
}
out := make([]ipc.Speaker, 0, len(ps))
for _, p := range ps {
out = append(out, toWireSpeaker(p))
}
return ipc.ListSpeakersResp{Speakers: out, Enabled: w.rec.Enabled()}, nil
}
func (w *speakerWiring) forget(ctx context.Context, req ipc.ForgetSpeakerReq) error {
return speakerErr(w.rec.Forget(ctx, req.ID))
}
// toWireSpeaker drops the voiceprint. A listing says who is enrolled; it does
// not hand the biometric back out over the socket.
func toWireSpeaker(p speaker.Profile) ipc.Speaker {
return ipc.Speaker{ID: p.ID, Name: p.Name, Enrolled: p.Enrolled, Samples: p.Samples}
}
// speakerErr maps the package sentinels onto the wire vocabulary so a surface
// can tell "you asked wrong" from "core broke".
func speakerErr(err error) error {
switch {
case err == nil:
return nil
case errors.Is(err, speaker.ErrNotFound):
return ipc.ErrNoFact
case errors.Is(err, speaker.ErrBadID),
errors.Is(err, speaker.ErrBadFormat),
errors.Is(err, speaker.ErrTooShort):
return errors.Join(ipc.ErrBadParams, err)
default:
return err
}
}
// wireSpeaker attaches the three handlers when the capability is configured.
func wireSpeaker(srv *ipc.Server, st *store.Store, cfg *config.Config) {
w := newSpeakerWiring(st, cfg)
if w == nil {
return
}
srv.EnrollSpeakerFn = w.enroll
srv.ListSpeakersFn = w.list
srv.ForgetSpeakerFn = w.forget
}
+67
View File
@@ -0,0 +1,67 @@
{
"schema_version": 1,
"name": "evening_degraded",
"description": "The tier-2 pipeline case #288 deferred here, plus degraded mode. A golden WAV goes in at the microphone end and comes out as a written fact, and then the ecosystem starts answering 503 and the proactive loop has to stay quiet instead of falling over. The audio step asserts the PIPELINE — mic to STT seam to router to store to TTS — not whisper's accuracy; cmd/mavsttd/golden_test.go owns accuracy.",
"start": "2026-08-01T21:00:00+03:00",
"praxis_attention": "[{\"id\":\"item_1\",\"title\":\"medicine not taken\",\"importance\":3.0,\"rule\":\"evening_medicine\"}]",
"script": [
{
"match": "выпил воды",
"route": "[{\"intent\":\"fact\",\"key\":\"water\",\"value\":\"выпил\"}]"
},
{
"match": "записала факт: water",
"reply": "{\"response\":\"Записала, что ты выпил воды.\",\"mood\":\"neutral\"}"
},
{
"match": "",
"route": "[{\"intent\":\"chat\",\"text\":\"привет\"}]",
"reply": "{\"response\":\"Я рада тебя слышать.\",\"mood\":\"happy\"}"
}
],
"steps": [
{
"at": "21:00",
"note": "he speaks. The whole voice path runs: push-to-talk, the STT seam parked with the golden transcript, the real router, the real store write, the phrasing contract.",
"audio": "ru_fact",
"expect_reply_contains": ["записала"],
"expect_reply_lacks": ["записал,", "милый", "ваш"],
"expect_events": ["water"]
},
{
"at": "21:05",
"note": "a healthy tick with him just having spoken stays silent",
"tick": true,
"expect_no_send": true
},
{
"at": "21:10",
"note": "the ecosystem goes down",
"fault": 503
},
{
"at": "21:15",
"note": "a tick against a dead ecosystem must degrade, not send half a thought",
"tick": true,
"expect_no_send": true,
"expect_no_events": true
},
{
"at": "21:20",
"note": "intake keeps working while the ecosystem is down — a write does not depend on it",
"arrive": {
"source": "rss:tech",
"note": { "text": "Патч 6.19.1 [tech]\nисправления\nhttps://example.org/b" }
},
"expect_events": ["rss:tech"],
"expect_no_send": true
},
{
"at": "21:25",
"note": "recovery",
"clear_fault": true,
"tick": true,
"expect_no_send": true
}
]
}
+96
View File
@@ -0,0 +1,96 @@
{
"schema_version": 1,
"name": "morning_missed",
"description": "The scenario from Vikunja #284's description, replayed. He appears at 08:30, things arrive through the morning while he is at the desk, and at 08:50 he asks what he missed. The assertions are as much about what did NOT happen — nothing was sent at him unprompted — as about what she said.",
"start": "2026-08-01T08:30:00+03:00",
"praxis_attention": "[{\"id\":\"item_1\",\"title\":\"medicine not taken\",\"importance\":3.0,\"rule\":\"morning_medicine\"}]",
"script": [
{
"match": "выпил воды",
"route": "[{\"intent\":\"fact\",\"key\":\"water\",\"value\":\"выпил\"}]"
},
{
"match": "записала факт: water",
"reply": "{\"response\":\"Записала, что ты выпил воды.\",\"mood\":\"neutral\"}"
},
{
"match": "что я пропустил",
"route": "[{\"intent\":\"query\",\"text\":\"что я пропустил\"}]"
},
{
"match": "",
"route": "[{\"intent\":\"chat\",\"text\":\"привет\"}]",
"reply": "{\"response\":\"Я рада тебя слышать.\",\"mood\":\"happy\"}"
}
],
"steps": [
{
"at": "08:30",
"note": "he appears at the desk",
"signal": { "key": "desk_active", "value": "true", "source": "infer:hyprland" },
"expect_events": ["infer:hyprland"],
"expect_no_send": true
},
{
"at": "08:32",
"note": "a feed item arrives, published half an hour ago",
"arrive": {
"source": "rss:tech",
"as_of": "08:02",
"note": { "text": "Вышло ядро 6.19 [tech]\nкраткое содержание\nhttps://example.org/a" }
},
"expect_events": ["rss:tech"],
"expect_no_send": true
},
{
"at": "08:35",
"note": "the mail reader extracts a candidate — a candidate is never spoken",
"arrive": {
"source": "email:inbox",
"task": { "text": "продлить домен", "evidence": "Домен истекает через 7 дней" }
},
"expect_events": ["email:inbox", "продлить домен"],
"expect_no_send": true
},
{
"at": "08:40",
"note": "the work calendar signal — a relayed notification, below full confidence",
"arrive": {
"source": "ambient:notif",
"fact": {
"key": "calendar_event_20260801_планёрка",
"value": "10:00-11:00 планёрка"
}
},
"expect_events": ["ambient:notif", "планёрка"],
"expect_no_send": true
},
{
"at": "08:45",
"note": "a tick with him present and nothing wrong must stay silent",
"tick": true,
"expect_no_send": true
},
{
"at": "08:50",
"note": "he asks. The query path answers from local recall only: nothing stored clears the score gate, so she refuses rather than inventing a morning summary, and the replier is never reached. That refusal is the no-hallucination floor and this step pins it.",
"say": "что я пропустил?",
"expect_reply_contains": ["не знаю"],
"expect_reply_lacks": ["рад ", "милый", "ваш"]
},
{
"at": "08:55",
"note": "stating a fact writes it and says so, in the feminine",
"say": "я выпил воды",
"expect_reply_contains": ["записала"],
"expect_reply_lacks": ["записал,", "милый"],
"expect_events": ["water"]
},
{
"at": "09:00",
"note": "a second tick, still nothing unprompted",
"tick": true,
"expect_no_send": true
}
]
}
+23
View File
@@ -941,6 +941,19 @@ type daemonAPI struct {
getMorningStatus func(ctx context.Context) []ipc.MorningRoutineStatus
getDayPlan func(ctx context.Context) ipc.DayPlan
chatFn func(ctx context.Context, text string) string
getMCPServers func() []ipc.MCPServerStatus
getEvents func(n int) []ipc.IntakeEvent
}
// RecentEvents — the unified intake journal (Vikunja #283). Empty, not an
// error, when no bus was wired: "nothing has arrived" and "the journal is off"
// look the same to a reader on purpose, because neither is a fault and the
// page renders both as an empty table.
func (d *daemonAPI) RecentEvents(ctx context.Context, n int) ([]ipc.IntakeEvent, error) {
if d.getEvents == nil {
return nil, nil
}
return d.getEvents(n), nil
}
func (d *daemonAPI) Chat(ctx context.Context, text string) (string, error) {
@@ -950,6 +963,16 @@ func (d *daemonAPI) Chat(ctx context.Context, text string) (string, error) {
return d.chatFn(ctx, text), nil
}
// MCPServers — the configured MCP servers and their health (Vikunja #251).
// Empty, not an error, when the mcp block is absent: "not configured" is the
// default state and the web surface renders it as such.
func (d *daemonAPI) MCPServers(ctx context.Context) ([]ipc.MCPServerStatus, error) {
if d.getMCPServers == nil {
return nil, nil
}
return d.getMCPServers(), nil
}
func (d *daemonAPI) TickTrace(ctx context.Context) (ipc.TickTrace, error) {
trace := d.getTrace()
if trace == nil {
+257
View File
@@ -0,0 +1,257 @@
// mavend/vision.go — core's half of image understanding (Vikunja #252,
// docs/plans/07-vision.md).
//
// The split: any surface that can receive a picture (mavweb upload, a Telegram
// photo through mavpoll, a path he names) hands the bytes to core over
// ipc.MethodDescribeImage. Core stores them content-addressed under
// media.dir, prepares a downscaled JPEG, and asks a local vision server what it
// is. The description comes back as words; nothing about the image is echoed.
//
// Off unless configured twice over: no `media` block ⇒ nowhere to keep the
// bytes, so the method does not exist; no `vision` block with enabled + a local
// endpoint ⇒ the store is wired but the describing half refuses, and the method
// still does not exist. A surface cannot make Maven look at pictures by merely
// sending one.
//
// Two things this file deliberately does not do:
//
// - No cloud vision call, ever. internal/vision refuses a non-private
// endpoint at construction; there is no config shape here that could reach
// an upstream API even if someone wanted one.
// - No automatic memory. SaveNote is opt-in per call. Glancing at a screenshot
// is not the same act as remembering it, and a 1.7B-class VLM's guess about
// a photo is not a fact worth carrying around.
package main
import (
"context"
"errors"
"fmt"
"log"
"path/filepath"
"time"
"github.com/kami/maven/internal/config"
"github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/media"
"github.com/kami/maven/internal/router"
"github.com/kami/maven/internal/store"
"github.com/kami/maven/internal/vision"
)
// prunePeriod — how often stored blobs are checked against media.retention.
// Hourly is far more often than needed for a 7-day retention and costs a
// directory walk over a handful of sidecars; the point is that the promise is
// kept by a loop that runs, not by an operator remembering a cron.
const prunePeriod = time.Hour
// mediaKeeper — the blob store plus the loop that enforces its retention. The
// two are one object because a store without the loop is a directory that grows
// forever, and shipping that would break the only interesting promise this
// capability makes.
type mediaKeeper struct {
store *media.Store
}
// openMediaStore builds the blob store from config, or returns nil when media is
// not configured. A relative dir resolves against StateDir, the same rule the db
// and socket paths follow.
func openMediaStore(cfg *config.Config) *mediaKeeper {
dir := cfg.Media.StoreDir()
if dir == "" {
return nil
}
if !filepath.IsAbs(dir) && cfg.StateDir != "" {
dir = filepath.Join(cfg.StateDir, dir)
}
st, err := media.Open(dir, cfg.Media.MaxBytes, time.Duration(cfg.Media.Retention))
if err != nil {
log.Printf("media: %v — image and audio intake disabled", err)
return nil
}
log.Printf("media: blob store at %s, retention %s", st.Dir(), st.Retention())
return &mediaKeeper{store: st}
}
// runPrune deletes over-retention blobs on a loop until ctx ends. It prunes once
// immediately, so a daemon restarted after a long downtime does not sit on a
// month of stale recordings until the first tick.
func (k *mediaKeeper) runPrune(ctx context.Context) {
prune := func() {
n, err := k.store.Prune()
if err != nil {
log.Printf("media: prune: %v", err)
return
}
if n > 0 {
log.Printf("media: pruned %d blob(s) older than %s", n, k.store.Retention())
}
}
prune()
t := time.NewTicker(prunePeriod)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
prune()
}
}
}
// visionIntake — one image at a time: store, prepare, describe, optionally note.
type visionIntake struct {
in *vision.Intake
st *store.Store
emb router.Embedder
now func() time.Time
}
// newVisionIntake returns nil when there is nothing to wire. keeper == nil means
// no media block, which disables the method outright; a missing or disabled
// vision block still wires the method, because storing an image and answering
// "I can't look at it yet" is more useful than pretending the surface does not
// exist — and it is exactly the state this box is in until a vision model is on
// disk.
func newVisionIntake(keeper *mediaKeeper, st *store.Store, emb router.Embedder, cfg *config.Config) *visionIntake {
if keeper == nil {
return nil
}
vc := cfg.Vision
maxDim := 0
var provider vision.Provider = vision.Disabled{}
if vc.LooksAtImages() {
p, err := vision.NewLocal(vision.Config{
Endpoint: vc.Endpoint,
Model: vc.Model,
Timeout: time.Duration(vc.Timeout),
MaxTokens: vc.MaxTokens,
Prompt: vc.Prompt,
})
if err != nil {
// A public endpoint, a hostname, a bad URL. Logged once here rather
// than failing every turn, and the store still works.
log.Printf("vision: %v — she can store images but not describe them", err)
} else {
provider = p
maxDim = vc.MaxDim
log.Printf("vision: enabled against %s", p.Endpoint())
}
} else {
log.Printf("vision: not configured — images are stored, not described")
}
return &visionIntake{
in: vision.NewIntake(keeper.store, provider, maxDim),
st: st,
emb: emb,
now: time.Now,
}
}
// describe handles one ipc.MethodDescribeImage call.
//
// A description failure is NOT an error out of this method when the bytes were
// stored: the caller gets the id and an empty description, which is honest ("it
// is kept, I cannot read it yet") and re-runnable. A failure to store, or bytes
// that are not an image at all, is an error — there is nothing to come back to.
func (v *visionIntake) describe(ctx context.Context, req ipc.DescribeImageReq) (ipc.DescribeImageResp, error) {
if len(req.Data) == 0 && req.ID == "" {
return ipc.DescribeImageResp{}, fmt.Errorf("describe image: neither data nor id")
}
var (
res vision.Result
err error
)
if req.ID != "" {
res, err = v.in.Rerun(ctx, req.ID, req.Question)
} else {
res, err = v.in.Accept(ctx, req.Data, sourceOrDefault(req.Source), req.Question)
}
if res.Blob.ID == "" {
// Nothing was stored: bad format, over the size cap, unwritable dir.
return ipc.DescribeImageResp{}, fmt.Errorf("describe image: %w", err)
}
resp := ipc.DescribeImageResp{
ID: res.Blob.ID,
Description: res.Description,
Width: res.Image.Width,
Height: res.Image.Height,
}
if err != nil {
// Bytes are safe, words are not available. The log names the blob and the
// reason; it never names what was in the picture.
if errors.Is(err, vision.ErrDisabled) {
log.Printf("vision: stored %s, no vision model configured", res.Blob)
} else {
log.Printf("vision: stored %s, describe failed: %v", res.Blob, err)
}
return resp, nil
}
if req.SaveNote {
id, werr := v.writeNote(ctx, res)
if werr != nil {
// The description is still returned: losing the note is worse as a
// silent failure than as a log line next to a successful answer.
log.Printf("vision: note write for %s failed: %v", res.Blob, werr)
} else {
resp.NoteID = id
}
}
log.Printf("vision: described %s (%dx%d)", res.Blob, res.Image.Width, res.Image.Height)
return resp, nil
}
// writeNote stores the description as an ordinary note so it is recallable. The
// note carries the blob id in its source, which is the only link back to the
// bytes — the note text is words about the picture, never the picture.
func (v *visionIntake) writeNote(ctx context.Context, res vision.Result) (int64, error) {
var vec []float32
if v.emb != nil {
// EmbedPassage, not Embed: a description is text being searched FOR, and
// the e5 embedder is asymmetric. Backwards here makes it unfindable by
// the question that should have matched it.
var err error
vec, err = router.EmbedPassage(ctx, v.emb, res.Description)
if err != nil {
return 0, fmt.Errorf("embed: %w", err)
}
}
source := "media:image:" + res.Blob.ID[:12]
return v.st.WriteNote(ctx, v.now(), res.Description, vec, source)
}
// sourceOrDefault labels a blob whose sender did not say where it came from.
func sourceOrDefault(s string) string {
if s == "" {
return "unknown"
}
return s
}
// wireVision installs the IPC hook and starts the retention loop, or leaves the
// hook nil so ipc.MethodDescribeImage reports ErrUnknownMethod. Called on both
// startup paths (unlocked boot and passkey unlock) so vision behaves the same
// either way.
//
// Returns the media keeper so the meeting recorder can share it: one blob store
// with one retention loop holds both the images and the audio, which is the
// whole point of internal/media being a shared package. nil ⇒ no media block,
// and neither capability exists.
func wireVision(ctx context.Context, srv *ipc.Server, st *store.Store, emb router.Embedder, cfg *config.Config) *mediaKeeper {
keeper := openMediaStore(cfg)
if keeper == nil {
return nil
}
go keeper.runPrune(ctx)
vi := newVisionIntake(keeper, st, emb, cfg)
if vi == nil {
return keeper
}
srv.DescribeImageFn = vi.describe
return keeper
}
+10
View File
@@ -52,6 +52,7 @@ import (
"time"
"github.com/kami/maven/internal/audio"
"github.com/kami/maven/internal/crawl"
"github.com/kami/maven/internal/dialogue"
"github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/memory"
@@ -82,6 +83,15 @@ type reactiveHandler struct {
replier voice.Replier
now func() time.Time
// crawler reads a web page he names out loud (queryWeb). nil ⇒ on-demand
// page reading is off, which is the default: no `crawl` block, no fetch.
crawler *crawl.Crawler
// feedsOn — whether any RSS feed is configured (config.Feeds). It changes
// only what she SAYS when asked and nothing is there: "ленты не настроены"
// instead of "ничего нового", which are different truths.
feedsOn bool
weatherProvider weather.Provider
weatherLocation string // default location for weather queries
+36 -11
View File
@@ -40,6 +40,15 @@ type voiceWiring struct {
// mavsttd / mavttsd don't keep a stale conn into a restarting daemon.
sttClient *worker.Client
ttsClient *worker.Client
// transcriber — the STT in use, exposed so the meeting recorder
// (cmd/mavend/capture.go) can reuse it. Maven has exactly one STT and does
// not grow a second one for capture: this is the same whisper.cpp worker the
// voice path talks to.
transcriber stt.Transcriber
// mcp — the MCP client, nil unless the `mcp` block configures an enabled
// server (Vikunja #251). Its tools land in the same allowlist as every
// other act, so nothing else here has to know about it.
mcp *mcpWiring
}
// close releases the listener + worker conns. Safe to call on nil (when
@@ -60,6 +69,7 @@ func (w *voiceWiring) close() {
if w.ttsClient != nil {
_ = w.ttsClient.Close()
}
w.mcp.close()
}
// wireVoice builds the audio path from cfg + a CoreAPI + a router. Returns
@@ -87,6 +97,7 @@ func wireVoice(cfg *config.Config, coreAPI ipc.CoreAPI, phr phraser.Phraser, mem
} else {
transcriber = stt.NewStub()
}
w.transcriber = transcriber
// ----- tts (Stub in-process OR Remote) -----
var synthesizer tts.Synthesizer
@@ -131,6 +142,14 @@ func wireVoice(cfg *config.Config, coreAPI ipc.CoreAPI, phr phraser.Phraser, mem
// daemon restart.
seedTools(coreAPI, cfg.Voice.Tools)
exec := tool.NewExecutor(coreAPI, time.Duration(cfg.Voice.ToolTimeout))
// MCP servers (Vikunja #251): discovery PROPOSES tools into the same
// allowlist, so an MCP tool is enabled by hand on /tools like any other and
// runs through the same confirm turn. Off unless the `mcp` block configures
// an enabled server.
w.mcp = wireMCP(cfg, dataStore)
if w.mcp != nil {
exec = exec.WithMCP(w.mcp.caller())
}
matcher := tool.NewMatcher(coreAPI)
// ----- weather provider (Open-Meteo when configured, Stub otherwise) -----
@@ -148,7 +167,9 @@ func wireVoice(cfg *config.Config, coreAPI ipc.CoreAPI, phr phraser.Phraser, mem
// The replier uses the same llama-server as the phraser.
var llmClient *llm.Client
if lp, ok := phr.(*phraser.LLMPhraser); ok {
llmClient = llm.New(lp.BaseURL(), 60*time.Second)
// llmClientFor, not llm.New: this client must follow the phraser onto
// the new llama-server when the resident model is swapped (Vikunja #250).
llmClient = llmClientFor(lp, 60*time.Second)
}
// ----- router (the cascade; floor examples seed the classifier) -----
// The act matcher's allowlist is exactly the enabled tool names — the
@@ -201,16 +222,20 @@ func wireVoice(cfg *config.Config, coreAPI ipc.CoreAPI, phr phraser.Phraser, mem
// ----- the handler (the reactive path; closes over stt / tts / router / coreAPI / memory) -----
h := &reactiveHandler{
stt: transcriber,
tts: synthesizer,
router: rtr,
embedder: emb,
api: coreAPI,
tools: exec,
matcher: matcher,
replier: replier,
phraser: phr,
now: time.Now,
stt: transcriber,
tts: synthesizer,
router: rtr,
embedder: emb,
api: coreAPI,
tools: exec,
matcher: matcher,
replier: replier,
phraser: phr,
now: time.Now,
feedsOn: cfg.Feeds != nil,
// nil unless `crawl.on_demand` is on: reading a page he names is a
// capability, and capabilities are off unless configured.
crawler: onDemandCrawler(cfg),
weatherProvider: weatherProvider,
weatherLocation: weatherLocation,
memStore: memStore,
+332
View File
@@ -0,0 +1,332 @@
// mavmaild — the mail reader module (Vikunja #246,
// docs/plans/01-email-reader.md).
//
// Every so often it opens one IMAP mailbox read-only, fetches the messages it
// has not read yet, and hands each one to core over ipc.MethodIngestMail. Core
// runs the extraction on the resident model and writes what comes back as task
// CANDIDATES he reviews on /tasks. Nothing here writes to the store, nothing
// here can create a reminder, and nothing here speaks.
//
// Why a separate daemon rather than a loop inside mavend, when extraction has
// to happen in mavend anyway: the credential. mavpoll set the precedent with the
// zenmoney token (#125) — the module that talks to a third party holds the
// secret, reads it from a FILE so it never appears in `ps`, in
// docker-compose.yml or in shell history, and core never sees it. Core learns
// that mail exists only as message text on one IPC method; it cannot connect to
// the mailbox even if it wanted to, and a compromised core yields no mail
// password.
//
// Off unless configured: without -password-file there is nothing to run, and
// the daemon says so and exits. If core has no `email` block the very first
// ingest comes back ErrUnknownMethod and this daemon stops polling instead of
// hammering a socket that will keep refusing.
//
// Mail is personal, so the log is counts and UIDs: how many messages were
// fetched, how many were bulk, how many candidates came back. No subject, no
// sender, no body, ever — reviewing a candidate is what /tasks is for.
package main
import (
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"log"
"os"
"os/signal"
"path/filepath"
"sort"
"strings"
"syscall"
"time"
"github.com/kami/maven/internal/email"
"github.com/kami/maven/internal/ipc"
)
func main() {
if err := run(os.Args[1:]); err != nil {
fmt.Fprintln(os.Stderr, "mavmaild:", err)
os.Exit(1)
}
}
func run(args []string) error {
fs := flag.NewFlagSet("mavmaild", flag.ContinueOnError)
socket := fs.String("socket", "", "core IPC socket path (required)")
server := fs.String("imap", "", "IMAP server, host or host:993 (required)")
user := fs.String("user", "", "IMAP username (required)")
passFile := fs.String("password-file", "", "file holding the IMAP password (required — never passed as a flag value)")
mailbox := fs.String("mailbox", "INBOX", "mailbox to read, read-only")
interval := fs.Duration("interval", 15*time.Minute, "how often to read the mailbox")
lookback := fs.Duration("lookback", 72*time.Hour, "how far back to search on each poll")
max := fs.Int("max", 25, "most messages to fetch in one poll")
timeout := fs.Duration("timeout", 30*time.Second, "IMAP network timeout")
statePath := fs.String("state", "", "file remembering which UIDs were read (default: none — every poll re-reads the window)")
if err := fs.Parse(args); err != nil {
return err
}
if *socket == "" {
return fmt.Errorf("-socket is required")
}
if *server == "" || *user == "" || *passFile == "" {
return fmt.Errorf("mail reading is off unless configured: set -imap, -user and -password-file")
}
// The password is read from a file, never taken as a flag value: an argv
// secret is visible in `ps` to every user on the box and lands in the compose
// file and the shell history. Read once at start — a rotated password means a
// restart, which is cheaper than re-reading his credential every quarter hour.
raw, err := os.ReadFile(*passFile)
if err != nil {
return fmt.Errorf("read password file: %w", err)
}
password := strings.TrimSpace(string(raw))
if password == "" {
return fmt.Errorf("password file %s is empty", *passFile)
}
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
core, err := ipc.DialWait(*socket, 60*time.Second)
if err != nil {
return err
}
defer core.Close()
r := &reader{
core: core,
addr: *server,
user: *user,
mailbox: *mailbox,
lookback: *lookback,
max: *max,
timeout: *timeout,
state: newSeenState(*statePath),
}
if err := r.state.load(); err != nil {
// A missing or corrupt state file must not stop mail from being read: the
// worst case is re-reading the window, and capture dedupes on text.
log.Printf("mavmaild: state: %v (starting from an empty seen-set)", err)
}
// The password is never logged, not even its length.
log.Printf("mavmaild: reading %s on %s every %s (lookback %s, max %d/poll)",
*mailbox, *server, *interval, *lookback, *max)
r.pollOnce(ctx, password) // don't idle a full interval on start
t := time.NewTicker(*interval)
defer t.Stop()
for {
select {
case <-ctx.Done():
log.Printf("mavmaild: bye")
return nil
case <-t.C:
if r.disabled {
// Core told us mail ingestion is not configured. Nothing will change
// without a core restart, and a restart restarts us too.
log.Printf("mavmaild: core does not accept mail — idling")
return nil
}
r.pollOnce(ctx, password)
}
}
}
// mailIngester — the slice of core this daemon uses. One method: hand over a
// message. It cannot write a fact, create a reminder or read the store, and the
// interface says so.
type mailIngester interface {
IngestMail(ctx context.Context, req ipc.IngestMailReq) (ipc.IngestMailResp, error)
}
type reader struct {
core mailIngester
addr string
user string
mailbox string
lookback time.Duration
max int
timeout time.Duration
state *seenState
// dial — connection seam for the tests; nil ⇒ implicit TLS.
dial func(addr string, timeout time.Duration) (*email.Conn, error)
// disabled — core answered ErrUnknownMethod, i.e. it has no email block.
disabled bool
}
// pollOnce — one read of the mailbox, then one ingest per message.
//
// A fetch error aborts this poll and nothing else; the next tick tries again.
// An ingest error for one message does not skip the rest — one mail the model
// choked on should not hide the four behind it.
func (r *reader) pollOnce(ctx context.Context, password string) {
msgs, err := r.fetch(password)
if err != nil {
// The error may name a UID; it never names a subject or a sender.
log.Printf("mavmaild: fetch: %v", err)
if len(msgs) == 0 {
return
}
}
var junk, candidates, created int
for _, m := range msgs {
if ctx.Err() != nil {
return
}
if m.Junk {
junk++
// Marked seen without a model call: the header filter already decided,
// and re-classifying it every quarter hour would be pure waste.
r.state.mark(m.UID)
continue
}
resp, err := r.core.IngestMail(ctx, ipc.IngestMailReq{
Mailbox: r.mailbox,
UID: m.UID,
From: m.From,
Subject: m.Subject,
Date: m.Date,
Body: m.Body,
})
if errors.Is(err, ipc.ErrUnknownMethod) {
log.Printf("mavmaild: core has no email block configured — mail ingestion is off; stopping")
r.disabled = true
return
}
if err != nil {
// Not marked seen: an ingest that failed should be retried next poll.
log.Printf("mavmaild: ingest uid %d: %v", m.UID, err)
continue
}
r.state.mark(m.UID)
candidates += len(resp.TaskIDs)
created += resp.Created
}
if err := r.state.save(); err != nil {
log.Printf("mavmaild: state: %v", err)
}
log.Printf("mavmaild: %s: %d read, %d bulk, %d candidate(s), %d new", r.mailbox, len(msgs), junk, candidates, created)
}
// fetch reads the mailbox. Messages already in the seen-set are not fetched at
// all, so a steady mailbox costs one SEARCH per poll and nothing else.
func (r *reader) fetch(password string) ([]email.Message, error) {
f := email.FetchSince{
Addr: r.addr,
User: r.user,
Mailbox: r.mailbox,
Timeout: r.timeout,
Since: time.Now().Add(-r.lookback),
Max: r.max,
Skip: r.state.seen,
}
return f.RunWith(password, r.dial)
}
// ---- seen state ------------------------------------------------------------
// seenState — the UIDs already handed to core, persisted so a restart does not
// re-read (and re-extract, at multi-second LLM cost) the whole lookback window.
//
// Correctness does not depend on it: ipc.CaptureTask dedupes on normalised text
// among live tasks, so a re-read produces no duplicate rows. This exists to save
// the model's time, which is why a broken state file is a log line rather than a
// failure.
//
// UIDs are per-mailbox and monotonic, so the set is kept as a high-water mark
// plus the stragglers above it. If the server ever changes UIDVALIDITY, UIDs
// reset and the window is simply re-read once — dedupe absorbs it.
type seenState struct {
path string
high uint32
set map[uint32]bool
dirty bool
}
func newSeenState(path string) *seenState {
return &seenState{path: path, set: map[uint32]bool{}}
}
type seenFile struct {
High uint32 `json:"high"`
UIDs []uint32 `json:"uids,omitempty"`
}
func (s *seenState) seen(uid uint32) bool {
return uid <= s.high || s.set[uid]
}
func (s *seenState) mark(uid uint32) {
if s.seen(uid) {
return
}
s.set[uid] = true
s.dirty = true
// Advance the high-water mark through any contiguous run, so the explicit set
// stays small on a mailbox read in order.
for {
next := s.high + 1
if !s.set[next] {
break
}
delete(s.set, next)
s.high = next
}
}
func (s *seenState) load() error {
if s.path == "" {
return nil
}
b, err := os.ReadFile(s.path)
if errors.Is(err, os.ErrNotExist) {
return nil // first run
}
if err != nil {
return err
}
var f seenFile
if err := json.Unmarshal(b, &f); err != nil {
return fmt.Errorf("parse %s: %w", s.path, err)
}
s.high = f.High
for _, u := range f.UIDs {
s.set[u] = true
}
return nil
}
// save writes the state atomically (temp file + rename), 0600: it is a list of
// message ids from his mailbox, which is metadata about his mail.
func (s *seenState) save() error {
if s.path == "" || !s.dirty {
return nil
}
uids := make([]uint32, 0, len(s.set))
for u := range s.set {
uids = append(uids, u)
}
sort.Slice(uids, func(i, j int) bool { return uids[i] < uids[j] })
b, err := json.Marshal(seenFile{High: s.high, UIDs: uids})
if err != nil {
return err
}
tmp := s.path + ".tmp"
if err := os.MkdirAll(filepath.Dir(s.path), 0o700); err != nil {
return err
}
if err := os.WriteFile(tmp, b, 0o600); err != nil {
return err
}
if err := os.Rename(tmp, s.path); err != nil {
return err
}
s.dirty = false
return nil
}
+254
View File
@@ -0,0 +1,254 @@
package main
import (
"bufio"
"context"
"fmt"
"net"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
"github.com/kami/maven/internal/email"
"github.com/kami/maven/internal/ipc"
)
// ---- a scripted IMAP server, same shape internal/email's tests use ---------
type fakeIMAP struct {
msgs map[uint32]string
uids []uint32
cmds []string
}
func (f *fakeIMAP) serve(c net.Conn) {
defer c.Close()
fmt.Fprint(c, "* OK fake ready\r\n")
r := bufio.NewReader(c)
for {
line, err := r.ReadString('\n')
if err != nil {
return
}
parts := strings.SplitN(strings.TrimRight(line, "\r\n"), " ", 2)
if len(parts) != 2 {
return
}
tag, cmd := parts[0], parts[1]
f.cmds = append(f.cmds, cmd)
upper := strings.ToUpper(cmd)
switch {
case strings.HasPrefix(upper, "LOGIN"), strings.HasPrefix(upper, "EXAMINE"):
fmt.Fprintf(c, "%s OK\r\n", tag)
case strings.HasPrefix(upper, "UID SEARCH"):
var ids []string
for _, u := range f.uids {
ids = append(ids, strconv.FormatUint(uint64(u), 10))
}
fmt.Fprintf(c, "* SEARCH %s\r\n%s OK\r\n", strings.Join(ids, " "), tag)
case strings.HasPrefix(upper, "UID FETCH"):
uid, _ := strconv.ParseUint(strings.Fields(cmd)[2], 10, 32)
if raw, ok := f.msgs[uint32(uid)]; ok {
fmt.Fprintf(c, "* 1 FETCH (UID %d BODY[] {%d}\r\n%s)\r\n", uid, len(raw), raw)
}
fmt.Fprintf(c, "%s OK\r\n", tag)
case strings.HasPrefix(upper, "LOGOUT"):
fmt.Fprintf(c, "* BYE\r\n%s OK\r\n", tag)
return
default:
fmt.Fprintf(c, "%s BAD\r\n", tag)
}
}
}
func (f *fakeIMAP) dial(_ string, timeout time.Duration) (*email.Conn, error) {
cli, srv := net.Pipe()
go f.serve(srv)
return email.NewConn(cli, timeout)
}
// ---- a fake core -----------------------------------------------------------
type fakeCore struct {
got []ipc.IngestMailReq
resp ipc.IngestMailResp
err error
}
func (c *fakeCore) IngestMail(_ context.Context, req ipc.IngestMailReq) (ipc.IngestMailResp, error) {
c.got = append(c.got, req)
if c.err != nil {
return ipc.IngestMailResp{}, c.err
}
return c.resp, nil
}
func mail(subject, body string, extraHeaders ...string) string {
h := "Subject: " + subject + "\r\nFrom: a@b.c\r\nContent-Type: text/plain; charset=utf-8\r\n"
for _, e := range extraHeaders {
h += e + "\r\n"
}
return h + "\r\n" + body + "\r\n"
}
func newTestReader(t *testing.T, f *fakeIMAP, core *fakeCore, statePath string) *reader {
t.Helper()
return &reader{
core: core, addr: "mail.example:993", user: "kami", mailbox: "INBOX",
lookback: 72 * time.Hour, max: 25, timeout: 5 * time.Second,
state: newSeenState(statePath),
dial: f.dial,
}
}
func TestPollHandsMessagesToCore(t *testing.T) {
f := &fakeIMAP{
uids: []uint32{1, 2},
msgs: map[uint32]string{
1: mail("Счёт", "Оплатить до 5 августа."),
2: mail("Скидки", "Sale!", "List-Unsubscribe: <mailto:u@x>"),
},
}
core := &fakeCore{resp: ipc.IngestMailResp{TaskIDs: []int64{1}, Created: 1}}
r := newTestReader(t, f, core, "")
r.pollOnce(context.Background(), "secret")
// The newsletter is filtered before core is asked: only the real mail crosses.
if len(core.got) != 1 {
t.Fatalf("core saw %d messages, want 1 (the bulk one must not cross): %+v", len(core.got), core.got)
}
got := core.got[0]
if got.UID != 1 || got.Mailbox != "INBOX" || got.Subject != "Счёт" {
t.Errorf("ingest req = %+v", got)
}
if !strings.Contains(got.Body, "Оплатить") {
t.Errorf("body = %q", got.Body)
}
}
// A second poll must not re-send what core already saw — extraction is a
// multi-second LLM call per message.
func TestPollSkipsSeenUIDs(t *testing.T) {
f := &fakeIMAP{uids: []uint32{5}, msgs: map[uint32]string{5: mail("Счёт", "текст")}}
core := &fakeCore{}
r := newTestReader(t, f, core, "")
r.pollOnce(context.Background(), "secret")
r.pollOnce(context.Background(), "secret")
if len(core.got) != 1 {
t.Errorf("core saw %d messages over two polls, want 1", len(core.got))
}
}
// An ingest that failed is NOT marked seen: the next poll retries it.
func TestPollRetriesFailedIngest(t *testing.T) {
f := &fakeIMAP{uids: []uint32{5}, msgs: map[uint32]string{5: mail("Счёт", "текст")}}
core := &fakeCore{err: fmt.Errorf("llama-server is warming up")}
r := newTestReader(t, f, core, "")
r.pollOnce(context.Background(), "secret")
core.err = nil
r.pollOnce(context.Background(), "secret")
if len(core.got) != 2 {
t.Errorf("core saw %d attempts, want 2 (a failed ingest is retried)", len(core.got))
}
}
// Core without an email block ⇒ stop, don't hammer the socket.
func TestPollStopsWhenCoreRefusesMail(t *testing.T) {
f := &fakeIMAP{uids: []uint32{1, 2}, msgs: map[uint32]string{1: mail("a", "b"), 2: mail("c", "d")}}
core := &fakeCore{err: fmt.Errorf("call: %w", ipc.ErrUnknownMethod)}
r := newTestReader(t, f, core, "")
r.pollOnce(context.Background(), "secret")
if !r.disabled {
t.Error("ErrUnknownMethod must disable the reader")
}
if len(core.got) != 1 {
t.Errorf("core saw %d messages, want 1 — stop at the first refusal", len(core.got))
}
}
func TestSeenStatePersists(t *testing.T) {
path := filepath.Join(t.TempDir(), "state", "seen.json")
f := &fakeIMAP{uids: []uint32{9}, msgs: map[uint32]string{9: mail("Счёт", "текст")}}
core := &fakeCore{}
r := newTestReader(t, f, core, path)
r.pollOnce(context.Background(), "secret")
fi, err := os.Stat(path)
if err != nil {
t.Fatalf("state file: %v", err)
}
// A list of message ids from his mailbox is metadata about his mail.
if perm := fi.Mode().Perm(); perm != 0o600 {
t.Errorf("state file mode = %v, want 0600", perm)
}
// A fresh reader with the same state file must not re-read the message.
core2 := &fakeCore{}
r2 := newTestReader(t, f, core2, path)
if err := r2.state.load(); err != nil {
t.Fatalf("load: %v", err)
}
r2.pollOnce(context.Background(), "secret")
if len(core2.got) != 0 {
t.Errorf("after a restart core saw %d messages, want 0", len(core2.got))
}
}
func TestSeenStateHighWaterMark(t *testing.T) {
s := newSeenState("")
s.mark(1)
s.mark(3)
s.mark(2)
if s.high != 3 {
t.Errorf("high = %d, want 3 (contiguous run collapses)", s.high)
}
if len(s.set) != 0 {
t.Errorf("explicit set = %v, want empty", s.set)
}
if !s.seen(2) || s.seen(4) {
t.Errorf("seen(2)=%v seen(4)=%v", s.seen(2), s.seen(4))
}
}
func TestSeenStateCorruptFileIsNotFatal(t *testing.T) {
path := filepath.Join(t.TempDir(), "seen.json")
if err := os.WriteFile(path, []byte("{not json"), 0o600); err != nil {
t.Fatal(err)
}
s := newSeenState(path)
if err := s.load(); err == nil {
t.Error("a corrupt state file should report an error the caller logs")
}
if s.seen(1) {
t.Error("a corrupt state file must leave an empty seen-set, not a poisoned one")
}
}
// Off unless configured, and the credential is never a flag value.
func TestRunRequiresConfig(t *testing.T) {
if err := run([]string{}); err == nil {
t.Error("no -socket must be an error")
}
if err := run([]string{"-socket", "/tmp/nope.sock"}); err == nil {
t.Error("no mailbox configuration must be an error, not a default mailbox")
}
// There is no -password flag at all: only -password-file.
if err := run([]string{"-socket", "/x", "-imap", "h", "-user", "u", "-password", "p"}); err == nil ||
!strings.Contains(err.Error(), "flag provided but not defined") {
t.Errorf("a -password flag must not exist; err = %v", err)
}
}
func TestRunRejectsEmptyPasswordFile(t *testing.T) {
path := filepath.Join(t.TempDir(), "pass")
if err := os.WriteFile(path, []byte(" \n"), 0o600); err != nil {
t.Fatal(err)
}
err := run([]string{"-socket", "/x/y.sock", "-imap", "h", "-user", "u", "-password-file", path})
if err == nil || !strings.Contains(err.Error(), "empty") {
t.Errorf("an empty password file must be refused before dialling; err = %v", err)
}
}
+124 -3
View File
@@ -9,6 +9,12 @@
// Two sources, each its own provenance (the loop's rules trust source):
// - netdata → poll:netdata resource alarms (disk/mem/cert/temp)
// - kuma → poll:uptimekuma service up/down (the source of truth for it)
// - zenmoney → poll:zenmoney spending/income totals (Vikunja #125)
//
// The zenmoney source is why the token lives HERE and not in core: the poller
// already owns every other third-party credential, it holds no store key, and
// core never needs to know an account exists to answer a question about a fact
// the poller wrote. It is off unless -zenmoney-token-file is given.
//
// Netdata needs no auth over the wg-fronted net. Kuma's /metrics needs an API
// key (basic-auth); without -kuma the whole kuma path is skipped (netdata-only
@@ -37,6 +43,7 @@ import (
"time"
"github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/zenmoney"
)
func main() {
@@ -52,6 +59,9 @@ func run(args []string) error {
netdataURL := fs.String("netdata", "http://127.0.0.1:19999", "netdata base URL ('' to disable)")
kumaURL := fs.String("kuma", "", "uptime-kuma metrics URL, e.g. http://127.0.0.1:3001/metrics ('' to disable)")
kumaKey := fs.String("kuma-key", "", "uptime-kuma API key (basic-auth username)")
zenTokenFile := fs.String("zenmoney-token-file", "", "file holding the zenmoney API token ('' disables money tracking)")
zenURL := fs.String("zenmoney-url", zenmoney.DefaultBaseURL, "zenmoney API base URL (tests/self-hosted proxies)")
zenInterval := fs.Duration("zenmoney-interval", time.Hour, "how often to read zenmoney (money does not move every minute)")
wgIface := fs.String("wg", "", "wireguard interface for the presence signal, e.g. wg0 or 'all' ('' to disable)")
wgCmd := fs.String("wg-cmd", "wg", "wg binary (use e.g. 'sudo wg' if the poller lacks CAP_NET_ADMIN)")
interval := fs.Duration("interval", 60*time.Second, "poll cadence")
@@ -62,8 +72,24 @@ func run(args []string) error {
if *socket == "" {
return fmt.Errorf("-socket is required")
}
if *netdataURL == "" && *kumaURL == "" && *wgIface == "" {
return fmt.Errorf("nothing to poll: set -netdata, -kuma and/or -wg")
if *netdataURL == "" && *kumaURL == "" && *wgIface == "" && *zenTokenFile == "" {
return fmt.Errorf("nothing to poll: set -netdata, -kuma, -wg and/or -zenmoney-token-file")
}
// The token is read from a file, never taken as a flag value: an argv token
// is visible in `ps` to every user on the box and lands in the compose file
// and the shell history. Read once at start — a rotated token means a
// restart, which is cheaper than re-reading his credential every hour.
var zen *zenmoney.Client
if *zenTokenFile != "" {
raw, err := os.ReadFile(*zenTokenFile)
if err != nil {
return fmt.Errorf("read zenmoney token: %w", err)
}
zen, err = zenmoney.New(strings.TrimSpace(string(raw)), *zenURL, *timeout*3)
if err != nil {
return err
}
}
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
@@ -83,9 +109,13 @@ func run(args []string) error {
kumaKey: *kumaKey,
wgIface: *wgIface,
wgCmd: *wgCmd,
zen: zen,
zenEvery: *zenInterval,
}
log.Printf("mavpoll: polling every %s (netdata=%q kuma=%q wg=%q)", *interval, *netdataURL, *kumaURL, *wgIface)
// The token is never logged, not even its length.
log.Printf("mavpoll: polling every %s (netdata=%q kuma=%q wg=%q zenmoney=%v every %s)",
*interval, *netdataURL, *kumaURL, *wgIface, zen != nil, *zenInterval)
p.pollOnce(ctx) // fire immediately; don't idle a full interval on start
t := time.NewTicker(*interval)
defer t.Stop()
@@ -108,6 +138,12 @@ type poller struct {
kumaKey string
wgIface string
wgCmd string
// zen is nil unless a token file was configured — money tracking is a
// capability, off by default like weather and telegram.
zen *zenmoney.Client
zenEvery time.Duration
zenLast time.Time
}
// pollOnce — one sweep of both sources. A failure in one source logs and does
@@ -129,6 +165,66 @@ func (p *poller) pollOnce(ctx context.Context) {
log.Printf("mavpoll: wg: %v", err)
}
}
// Money on its own, much slower cadence: a bank feed that updates hourly
// polled every minute is 60 pointless reads of his financial history.
if p.zen != nil && now.Sub(p.zenLast) >= p.zenEvery {
p.zenLast = now
if err := p.pollZenmoney(ctx, now); err != nil {
log.Printf("mavpoll: zenmoney: %v", err)
}
}
}
// ---- zenmoney: spending/income totals → money facts ------------------------
// pollZenmoney reads today's and this month's totals and writes them as
// facts(kind=env, source=poll:zenmoney) (Vikunja #125).
//
// Two properties this function exists to hold:
//
// - An empty or failed read writes NOTHING. zenmoney.Summary.Value() refuses
// to encode a summary built from zero transactions, so a poller that cannot
// reach the API leaves the last good fact in place rather than overwriting
// it with a zero Maven would then recite as fact.
// - Nothing about the money leaves the box except the diff request itself, to
// the service that already holds his bank sessions. The totals are written
// to the store and read back only when he asks; they are never search input
// and no tick rule fires on them.
//
// Both windows are read from one diff call each. Two calls an hour against an
// API whose whole job is this is not worth caching.
// moneyWindow — one fact key and the period it covers.
type moneyWindow struct {
key string
from, to time.Time
}
func (p *poller) pollZenmoney(ctx context.Context, now time.Time) error {
dFrom, dTo := zenmoney.DayWindow(now)
mFrom, mTo := zenmoney.MonthWindow(now)
windows := []moneyWindow{
{zenmoney.KeySpentToday, dFrom, dTo},
{zenmoney.KeySpentMonth, mFrom, mTo},
}
var firstErr error
for _, w := range windows {
sum, err := p.zen.Since(ctx, w.from, w.to)
if err != nil {
if firstErr == nil {
firstErr = err
}
continue
}
val, ok := sum.Value()
if !ok {
// Nothing read. Silence, not a zero.
continue
}
if err := p.writeIfChangedRaw(ctx, w.key, zenmoney.Source, val, now); err != nil && firstErr == nil {
firstErr = err
}
}
return firstErr
}
// ---- wireguard: latest handshake → presence signal -------------------------
@@ -301,6 +397,31 @@ func (p *poller) writeIfChanged(ctx context.Context, key, source, val string, no
return nil
}
// writeIfChangedRaw is writeIfChanged for values that are already JSON (the
// money facts store an object, not a string). Kept separate rather than
// generalising writeIfChanged, because the string-valued env facts encoding
// their own value is the convention the rules rely on.
//
// The log line names the key and the source, never the figures: mavpoll's log
// is not the place his spending ends up.
func (p *poller) writeIfChangedRaw(ctx context.Context, key, source, jsonVal string, now time.Time) error {
prev, err := p.core.LatestFactBySource(ctx, key, source)
switch {
case err == nil && prev.Value == jsonVal:
return nil
case err != nil && err != ipc.ErrNoFact && !isNoFact(err):
return fmt.Errorf("read %s: %w", key, err)
}
if _, err := p.core.WriteFact(ctx, ipc.WriteFactReq{
Ts: now, Kind: "env", Key: key, Value: jsonVal,
Source: source, Confidence: 1.0,
}); err != nil {
return fmt.Errorf("write %s: %w", key, err)
}
log.Printf("mavpoll: %s updated (%s)", key, source)
return nil
}
// isNoFact — ErrNoFact rehydrated over the wire is wrapped (fmt.Errorf %w), so
// errors.Is is the right check; keep a helper so the switch above reads clean.
func isNoFact(err error) bool {
+126
View File
@@ -1,8 +1,17 @@
package main
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"time"
"github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/zenmoney"
)
func TestMaxSeverity(t *testing.T) {
@@ -62,3 +71,120 @@ func TestParseMaxHandshake(t *testing.T) {
}
}
}
// ---- zenmoney (Vikunja #125) ----------------------------------------------
// factCore records the facts the poller wrote and answers "no fact yet".
type factCore struct {
ipc.UnimplementedCoreAPI
written []ipc.WriteFactReq
prev map[string]string
}
func (c *factCore) LatestFactBySource(_ context.Context, key, source string) (ipc.Fact, error) {
if v, ok := c.prev[key+"|"+source]; ok {
return ipc.Fact{Key: key, Source: source, Value: v}, nil
}
return ipc.Fact{}, ipc.ErrNoFact
}
func (c *factCore) WriteFact(_ context.Context, req ipc.WriteFactReq) (int64, error) {
c.written = append(c.written, req)
return int64(len(c.written)), nil
}
func zenFixtureServer(t *testing.T, body []byte, status int) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if status != http.StatusOK {
w.WriteHeader(status)
return
}
w.Write(body)
}))
}
func TestPollZenmoneyWritesMoneyFacts(t *testing.T) {
body, err := os.ReadFile("../../internal/zenmoney/testdata/diff.json")
if err != nil {
t.Fatal(err)
}
srv := zenFixtureServer(t, body, http.StatusOK)
defer srv.Close()
zen, err := zenmoney.New("tok", srv.URL, time.Second)
if err != nil {
t.Fatal(err)
}
core := &factCore{}
p := &poller{core: core, zen: zen}
now := time.Date(2026, 8, 1, 21, 0, 0, 0, time.UTC)
if err := p.pollZenmoney(context.Background(), now); err != nil {
t.Fatal(err)
}
if len(core.written) != 2 {
t.Fatalf("wrote %d facts, want today + month", len(core.written))
}
for _, f := range core.written {
if f.Kind != "env" || f.Source != zenmoney.Source {
t.Errorf("fact = %+v, want kind=env source=%s", f, zenmoney.Source)
}
if _, err := zenmoney.ParseFactValue(f.Value); err != nil {
t.Errorf("fact value %q does not decode: %v", f.Value, err)
}
}
}
// A read that returns nothing for the window writes NOTHING. Silence, not a
// zero: an invented 0 would be recited back to him as fact.
func TestPollZenmoneyWritesNothingWhenEmpty(t *testing.T) {
srv := zenFixtureServer(t, []byte(`{"serverTimestamp":1,"instrument":[],"transaction":[]}`), http.StatusOK)
defer srv.Close()
zen, _ := zenmoney.New("tok", srv.URL, time.Second)
core := &factCore{}
p := &poller{core: core, zen: zen}
if err := p.pollZenmoney(context.Background(), time.Now()); err != nil {
t.Fatal(err)
}
if len(core.written) != 0 {
t.Errorf("wrote %+v, want no fact at all", core.written)
}
}
// An API failure must not overwrite the last good total either.
func TestPollZenmoneyFailureWritesNothing(t *testing.T) {
srv := zenFixtureServer(t, nil, http.StatusUnauthorized)
defer srv.Close()
zen, _ := zenmoney.New("bad", srv.URL, time.Second)
core := &factCore{}
p := &poller{core: core, zen: zen}
if err := p.pollZenmoney(context.Background(), time.Now()); err == nil {
t.Error("want the 401 reported")
}
if len(core.written) != 0 {
t.Errorf("wrote %+v on a failed read", core.written)
}
}
// Unchanged totals do not churn the facts table.
func TestWriteIfChangedRawSkipsUnchanged(t *testing.T) {
core := &factCore{prev: map[string]string{
zenmoney.KeySpentToday + "|" + zenmoney.Source: `{"count":1}`,
}}
p := &poller{core: core}
if err := p.writeIfChangedRaw(context.Background(), zenmoney.KeySpentToday, zenmoney.Source, `{"count":1}`, time.Now()); err != nil {
t.Fatal(err)
}
if len(core.written) != 0 {
t.Errorf("wrote %+v for an unchanged value", core.written)
}
}
// Money tracking is off unless configured: no token file, no zenmoney client,
// and the poller still refuses to start with nothing at all to poll.
func TestRunRequiresSomethingToPoll(t *testing.T) {
err := run([]string{"-socket", "/tmp/nope.sock", "-netdata", "", "-kuma", "", "-wg", ""})
if err == nil || !strings.Contains(err.Error(), "nothing to poll") {
t.Errorf("err = %v, want a 'nothing to poll' refusal", err)
}
}
+323
View File
@@ -0,0 +1,323 @@
package main
// Golden-audio STT tests (Vikunja #288).
//
// These push real audio through the real whisper.cpp binding, so a bad model
// path, a wrong language hint, a broken resample or a regressed silence gate
// is caught by `make test` rather than by the owner talking to a daemon that
// mishears him.
//
// The fixtures are piper-synthesised, not recorded — see
// scripts/gen-stt-fixtures.sh. Nothing of the owner's voice is committed, and
// any fixture can be rebuilt from the script plus a voice model.
//
// Matching is deliberately tolerant. Golden transcripts are model-dependent:
// swapping ggml-small for a different whisper build moves punctuation, casing
// and the odd word ending, and an exact-string assertion would turn every
// model swap into a fixture rewrite. Each case therefore asserts two things —
// the words that carry the intent are present, and the word error rate
// against the reference stays under a per-case ceiling.
import (
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"unicode"
"github.com/kami/maven/internal/audio"
"github.com/kami/maven/internal/worker"
)
// goldenModelPath — the whisper model the golden tests run against. Same file
// the Makefile's run-stt target uses. Overridable so a box that keeps its
// models elsewhere can still run these.
func goldenModelPath() string {
if p := os.Getenv("MAVEN_WHISPER_MODEL"); p != "" {
return p
}
return filepath.Join("..", "..", "models", "stt", "ggml-small.bin")
}
type goldenCase struct {
Name string `json:"name"`
WAV string `json:"wav"`
Lang string `json:"lang"`
Text string `json:"text"`
Keywords []string `json:"keywords"`
MaxWER float64 `json:"max_wer"`
}
type goldenManifest struct {
Cases []goldenCase `json:"cases"`
}
func loadGoldenManifest(t *testing.T) goldenManifest {
t.Helper()
raw, err := os.ReadFile(filepath.Join("testdata", "golden_v1.json"))
if err != nil {
t.Fatalf("read golden manifest: %v", err)
}
var m goldenManifest
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatalf("parse golden manifest: %v", err)
}
if len(m.Cases) == 0 {
t.Fatal("golden manifest has no cases")
}
return m
}
// normalizeTranscript lowercases, drops punctuation, folds the Russian ё onto
// е (whisper is inconsistent about it and the router does not care), and
// collapses whitespace. Everything the comparison does happens on this form.
func normalizeTranscript(s string) []string {
var b strings.Builder
for _, r := range strings.ToLower(s) {
switch {
case r == 'ё':
b.WriteRune('е')
case unicode.IsLetter(r) || unicode.IsDigit(r):
b.WriteRune(r)
default:
b.WriteRune(' ')
}
}
return strings.Fields(b.String())
}
// wordErrorRate is the Levenshtein distance between two word sequences,
// divided by the length of the reference. 0 means identical; it can exceed 1
// when the hypothesis is much longer than the reference.
func wordErrorRate(ref, hyp []string) float64 {
if len(ref) == 0 {
if len(hyp) == 0 {
return 0
}
return 1
}
prev := make([]int, len(hyp)+1)
cur := make([]int, len(hyp)+1)
for j := range prev {
prev[j] = j
}
for i := 1; i <= len(ref); i++ {
cur[0] = i
for j := 1; j <= len(hyp); j++ {
cost := 1
if ref[i-1] == hyp[j-1] {
cost = 0
}
cur[j] = min(prev[j]+1, min(cur[j-1]+1, prev[j-1]+cost))
}
prev, cur = cur, prev
}
return float64(prev[len(hyp)]) / float64(len(ref))
}
// missingKeywords returns the keywords absent from the hypothesis. A keyword
// matches on prefix, so a different case ending ("воды" vs "воду") does not
// fail the assertion — the router's stage-0 grammar is stem-shaped too.
func missingKeywords(keywords []string, hyp []string) []string {
var missing []string
for _, kw := range keywords {
want := normalizeTranscript(kw)
if len(want) == 0 {
continue
}
if !containsSeq(hyp, want) {
missing = append(missing, kw)
}
}
return missing
}
func containsSeq(hyp, want []string) bool {
for i := 0; i+len(want) <= len(hyp); i++ {
ok := true
for j, w := range want {
// Prefix match, so inflection differences pass but
// distinct words do not.
if !looseWordMatch(hyp[i+j], w) {
ok = false
break
}
}
if ok {
return true
}
}
return false
}
func looseWordMatch(got, want string) bool {
if got == want {
return true
}
g, w := []rune(got), []rune(want)
n := len(w) - 1
if len(w) > 6 {
n = len(w) - 2
}
// Words of three runes or fewer have no room for a safe prefix: require
// an exact match rather than letting "час" pass for "часть".
if n < 3 || len(g) < n {
return false
}
return string(g[:n]) == string(w[:n])
}
// --- the model-backed test -------------------------------------------------
func TestGoldenAudioTranscription(t *testing.T) {
m := loadGoldenManifest(t)
model := goldenModelPath()
if _, err := os.Stat(model); err != nil {
t.Skipf("whisper model %s absent (%v) — set MAVEN_WHISPER_MODEL or see AGENTS.md", model, err)
}
// Same gate thresholds as mavsttd's defaults, so a regression in the
// silence gate shows up here as an empty transcript.
h, err := newWhisperHandler(model, 300, 0.01)
if err != nil {
t.Fatalf("load whisper model %s: %v", model, err)
}
defer h.Close()
for _, c := range m.Cases {
t.Run(c.Name, func(t *testing.T) {
path := filepath.Join("testdata", c.WAV)
raw, err := os.ReadFile(path)
if err != nil {
t.Skipf("fixture %s absent (%v) — run scripts/gen-stt-fixtures.sh", path, err)
}
format, pcm, err := audio.PCMFromWAV(raw)
if err != nil {
t.Fatalf("%s is not canonical 16k mono PCM: %v", path, err)
}
resp, err := h.Transcribe(context.Background(), worker.TranscribeReq{
Audio: audio.Audio{Format: format, Bytes: pcm},
Lang: c.Lang,
})
if err != nil {
t.Fatalf("transcribe %s: %v", c.WAV, err)
}
t.Logf("%s → %q (confidence %.3f)", c.WAV, resp.Text, resp.Confidence)
if strings.TrimSpace(resp.Text) == "" {
t.Fatalf("%s transcribed to empty text — the silence gate ate real speech", c.WAV)
}
if resp.Confidence <= 0 {
t.Errorf("%s: confidence %v, want > 0", c.WAV, resp.Confidence)
}
hyp := normalizeTranscript(resp.Text)
ref := normalizeTranscript(c.Text)
if missing := missingKeywords(c.Keywords, hyp); len(missing) > 0 {
t.Errorf("%s: missing keywords %v in %q", c.WAV, missing, resp.Text)
}
if wer := wordErrorRate(ref, hyp); wer > c.MaxWER {
t.Errorf("%s: WER %.2f > %.2f\n want: %q\n got: %q", c.WAV, wer, c.MaxWER, c.Text, resp.Text)
}
})
}
}
// TestGoldenFixturesAreCanonical checks the committed audio without needing a
// model, so a fixture regenerated at the wrong sample rate fails on every box.
func TestGoldenFixturesAreCanonical(t *testing.T) {
m := loadGoldenManifest(t)
for _, c := range m.Cases {
path := filepath.Join("testdata", c.WAV)
raw, err := os.ReadFile(path)
if err != nil {
t.Errorf("fixture %s missing: %v", path, err)
continue
}
format, pcm, err := audio.PCMFromWAV(raw)
if err != nil {
t.Errorf("%s: %v", path, err)
continue
}
if !format.IsValid() {
t.Errorf("%s: format %+v is not canonical", path, format)
}
a := audio.Audio{Format: format, Bytes: pcm}
if d := a.Duration(); d < 0.5 || d > 10 {
t.Errorf("%s: duration %.2fs outside the sane 0.510s fixture range", path, d)
}
// The fixture must clear mavsttd's own silence gate, otherwise the
// model test below would be asserting on a gated empty string.
if reason := gateReason(pcmToF32(pcm), whisperSampleRate, 300, 0.01); reason != "" {
t.Errorf("%s: would be gated as %s", path, reason)
}
if len(c.Keywords) == 0 {
t.Errorf("%s: manifest case has no keywords", c.Name)
}
if c.MaxWER <= 0 || c.MaxWER > 1 {
t.Errorf("%s: max_wer %v outside (0,1]", c.Name, c.MaxWER)
}
}
}
func pcmToF32(b []byte) []float32 {
out := make([]float32, len(b)/2)
for i := range out {
s := int16(b[i*2]) | int16(b[i*2+1])<<8
out[i] = float32(s) / 32768.0
}
return out
}
// --- matcher unit tests (no model, no fixtures) ----------------------------
func TestNormalizeTranscript(t *testing.T) {
got := normalizeTranscript(" Ещё, Раз... ")
want := []string{"еще", "раз"}
if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] {
t.Fatalf("normalizeTranscript = %v, want %v", got, want)
}
}
func TestWordErrorRate(t *testing.T) {
cases := []struct {
name string
ref, hyp string
want float64
}{
{"identical", "напомни мне через час", "Напомни мне через час.", 0},
{"one substitution", "напомни мне через час", "напомни мне через день", 0.25},
{"one deletion", "напомни мне через час", "напомни мне час", 0.25},
{"empty hypothesis", "напомни мне", "", 1},
{"both empty", "", "", 0},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := wordErrorRate(normalizeTranscript(c.ref), normalizeTranscript(c.hyp))
if got != c.want {
t.Fatalf("WER = %v, want %v", got, c.want)
}
})
}
}
func TestMissingKeywords(t *testing.T) {
hyp := normalizeTranscript("Отметь, что я выпил воду.")
if got := missingKeywords([]string{"воды", "отметь"}, hyp); len(got) != 0 {
t.Fatalf("missingKeywords = %v, want none (inflection must not fail the match)", got)
}
if got := missingKeywords([]string{"календарю"}, hyp); len(got) != 1 {
t.Fatalf("missingKeywords = %v, want the absent keyword reported", got)
}
// A short word must match exactly — no 4-rune prefix shortcut that would
// let "час" pass for "часть".
hyp2 := normalizeTranscript("через час")
if got := missingKeywords([]string{"часть"}, hyp2); len(got) != 1 {
t.Fatalf("missingKeywords = %v, want %q reported missing", got, "часть")
}
}
BIN
View File
Binary file not shown.
+37
View File
@@ -0,0 +1,37 @@
{
"note": "Golden STT fixtures. Audio is piper-synthesised, not recorded — see scripts/gen-stt-fixtures.sh. Regenerate with that script; do not hand-edit `wav`.",
"cases": [
{
"name": "ru_reminder",
"wav": "ru_reminder.wav",
"lang": "ru",
"text": "напомни мне через час позвонить маме",
"keywords": ["напомни", "час", "позвонить"],
"max_wer": 0.34
},
{
"name": "ru_fact",
"wav": "ru_fact.wav",
"lang": "ru",
"text": "отметь что я выпил воды",
"keywords": ["отметь", "воды"],
"max_wer": 0.34
},
{
"name": "ru_query",
"wav": "ru_query.wav",
"lang": "ru",
"text": "что у меня сегодня по календарю",
"keywords": ["сегодня", "календарю"],
"max_wer": 0.34
},
{
"name": "en_act",
"wav": "en_act.wav",
"lang": "en",
"text": "restart the web server and check the disk space",
"keywords": ["restart", "server", "disk"],
"max_wer": 0.34
}
]
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
+204
View File
@@ -0,0 +1,204 @@
// Command mavupdate deploys a new build of Maven to the box she runs on, with
// an automatic rollback when the new build does not come up (Vikunja #249).
//
// It is a CLI on purpose, and it is the ONLY trigger for the update path.
//
// The obvious design — an IPC method plus a button on the web UI behind the
// step-up passkey gate, the way /tools works — was considered and refused. A
// step-up gate protects against the wrong person clicking; it does not change
// the fact that anything reachable over the network becomes, in the event of a
// mavweb bug, a remote arbitrary-code path with a build system attached. An
// update needs shell access on the host, which is a strictly higher bar than
// the gate that guards the tool allowlist. That is deliberate and it is the
// reason there is no MethodApplyUpdate anywhere in internal/ipc.
//
// Consequently: mavend does not import internal/update, nothing runs on a timer,
// nothing checks a release server, and no act, intent, tool or LLM output can
// reach any of this. She cannot update herself. She can be updated, by him.
//
// mavupdate -config deploy/mavend.json list # snapshots available to roll back to
// mavupdate -config deploy/mavend.json verify # make build + make test, deploys nothing
// mavupdate -config deploy/mavend.json apply -yes # the whole thing
// mavupdate -config deploy/mavend.json rollback [id] # restore + restart (default: newest)
package main
import (
"context"
"errors"
"flag"
"fmt"
"os"
"os/signal"
"syscall"
"time"
"github.com/kami/maven/internal/config"
"github.com/kami/maven/internal/update"
)
func main() {
cfgPath := flag.String("config", "deploy/mavend.json", "path to mavend.json (the update block is read from it)")
yes := flag.Bool("yes", false, "required by `apply` and `rollback`: yes, restart the daemon")
flag.Usage = usage
flag.Parse()
// The stdlib flag package stops parsing at the first non-flag argument, so a
// `-yes` written after the subcommand (which is how anyone would type it, and
// how the usage text shows it) lands in Args instead of the flag. Pick it out
// by hand rather than silently treating "apply -yes" as an unconfirmed apply.
var args []string
for _, a := range flag.Args() {
if a == "-yes" || a == "--yes" {
*yes = true
continue
}
args = append(args, a)
}
if len(args) == 0 {
usage()
os.Exit(2)
}
cfg, err := config.Load(*cfgPath)
if err != nil {
die("config: %v", err)
}
if cfg.Update == nil {
die("no `update` block in %s — the update capability is off unless configured.\nSee the package comment in internal/update for what it does and does not do.", *cfgPath)
}
logf := func(format string, a ...any) {
fmt.Fprintf(os.Stderr, "%s %s\n", time.Now().Format("15:04:05"), fmt.Sprintf(format, a...))
}
u, err := update.New(*cfg.Update, update.WithLogger(logf))
if err != nil {
die("%v", err)
}
// Ctrl-C cancels the build or the health wait. It cannot cancel a rollback
// midway into leaving the box in an unknown state, because the rollback runs
// on its own context — see cmdApply.
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
switch args[0] {
case "list":
cmdList(u)
case "verify":
cmdVerify(ctx, u)
case "apply":
if !*yes {
die("apply restarts mavend and can roll her back. Re-run with -yes if that is what you want.")
}
cmdApply(ctx, u)
case "rollback":
if !*yes {
die("rollback restores the previous artifacts and restarts mavend. Re-run with -yes.")
}
id := ""
if len(args) > 1 {
id = args[1]
}
cmdRollback(ctx, u, id)
default:
usage()
os.Exit(2)
}
}
func cmdList(u *update.Updater) {
snaps, err := u.Snapshots()
if err != nil {
die("snapshots: %v", err)
}
if len(snaps) == 0 {
fmt.Println("no snapshots yet — the first `apply` takes one before it builds anything")
return
}
fmt.Printf("%-18s %-12s %s\n", "SNAPSHOT", "COMMIT", "FILES")
for _, s := range snaps {
commit := s.Commit
if len(commit) > 12 {
commit = commit[:12]
}
if commit == "" {
commit = "-"
}
fmt.Printf("%-18s %-12s %d\n", s.ID, commit, len(s.Files))
}
fmt.Printf("\nrollback to the newest with: mavupdate rollback -yes\n")
}
func cmdVerify(ctx context.Context, u *update.Updater) {
steps, err := u.Verify(ctx)
report(steps)
if err != nil {
die("%v", err)
}
fmt.Println("verified: the tree builds and passes its own tests. Nothing was deployed — run `apply -yes` for that.")
}
func cmdApply(ctx context.Context, u *update.Updater) {
res, err := u.Apply(ctx)
report(res.Steps)
summarize(res)
switch {
case err == nil:
fmt.Println("\nupdate committed: she answers on the new build.")
case errors.Is(err, update.ErrRollbackFailed):
die("\n%v\n\nSHE IS PROBABLY DOWN. The previous artifacts are in the snapshot dir; copy them\nover the install dir and restart by hand.", err)
case errors.Is(err, update.ErrRolledBack):
die("\n%v\n\nShe is answering again on the previous build. Nothing was lost; fix the change and retry.", err)
default:
die("\n%v", err)
}
}
func cmdRollback(ctx context.Context, u *update.Updater, id string) {
res, err := u.Rollback(ctx, id)
report(res.Steps)
summarize(res)
if err != nil && !errors.Is(err, update.ErrRolledBack) {
die("\n%v", err)
}
fmt.Printf("\nrolled back to %s; she answers on it.\n", res.SnapshotID)
}
func report(steps []update.Step) {
for _, s := range steps {
status := "ok"
if s.Err != nil {
status = "FAILED: " + s.Err.Error()
}
fmt.Printf(" %-8s %-8s %s\n", s.Name, s.Took.Round(time.Second), status)
if s.Output != "" {
fmt.Printf("---- %s output ----\n%s\n-------------------\n", s.Name, s.Output)
}
}
}
func summarize(res update.Result) {
fmt.Printf("\nverified=%v snapshot=%s installed=%d restarted=%v healthy=%v rolled_back=%v rollback_healthy=%v took=%s\n",
res.Verified, res.SnapshotID, len(res.Installed), res.Restarted, res.Healthy, res.RolledBack, res.RollbackHealthy, res.Took.Round(time.Second))
}
func usage() {
fmt.Fprint(os.Stderr, `mavupdate — deploy a new build of Maven, with rollback.
mavupdate [-config path] list
mavupdate [-config path] verify
mavupdate [-config path] apply -yes
mavupdate [-config path] rollback [snapshot-id] -yes
apply is: health-check the running daemon, snapshot the deployed artifacts,
make build, make test, install, restart, health-check — and restore the
snapshot if any of that fails. It never fetches code and never runs by itself.
`)
flag.PrintDefaults()
}
func die(format string, a ...any) {
fmt.Fprintf(os.Stderr, format+"\n", a...)
os.Exit(1)
}
+33 -92
View File
@@ -12,8 +12,15 @@
// (30ms frames, 16kHz PCM) matches silero-vad's input interface exactly, so
// swapping energy-threshold for ONNX-inference is a local change in vad.go.
//
// While a reply is playing the capture side is muted (half-duplex): without
// it, Maven's own voice comes back in through the mic and she answers
// herself. -barge-in punches one hole in that gate — sustained energy well
// above the speaker's leak level cuts playback so he can talk over her. It is
// off by default because the threshold is room-specific; see playback.go.
//
// usage:
// mavwaked # default ALSA device, 127.0.0.1:9100
// mavwaked -barge-in # let him interrupt her mid-reply
// mavwaked -device hw:1,0 -addr 10.42.0.1:9100
// mavwaked -test file.wav # read from file, no arecord
package main
@@ -60,6 +67,9 @@ func run(args []string) error {
silenceMs := flag.Int("silence-ms", defaultSilenceMs, "silence ms to end utterance")
maxMs := flag.Int("max-ms", defaultMaxMs, "max utterance ms")
testFile := flag.String("test", "", "read PCM from file instead of arecord (testing only)")
bargeIn := flag.Bool("barge-in", false, "cut Maven off when he talks over her (needs a room-tuned -barge-in-rms)")
bargeRMS := flag.Int("barge-in-rms", defaultBargeRMS, "RMS x10000 a frame must clear to count as barge-in")
bargeFrames := flag.Int("barge-in-frames", defaultBargeFrames, "consecutive frames over -barge-in-rms before playback is cut")
flag.CommandLine.Parse(args)
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM, syscall.SIGHUP)
@@ -117,14 +127,21 @@ func run(args []string) error {
defer src.Close()
return captureLoop(ctx, src, vad, vc, *lang)
var barge bargeInConfig
if *bargeIn {
barge = bargeInConfig{RMS: float64(*bargeRMS) / 10000.0, Frames: *bargeFrames}
log.Printf("mavwaked: barge-in on (rms %.4f x %d frames)", barge.RMS, barge.Frames)
}
sess := newSession(vad, newAplayPlayer(), &voiceSender{vc: vc}, *lang, barge)
return captureLoop(ctx, src, sess)
}
// captureLoop reads PCM from src, runs VAD, and sends complete utterances to
// the voice server. Returns when ctx is done or src is exhausted.
func captureLoop(ctx context.Context, src io.Reader, vad *VAD, vc *voice.Client, lang string) error {
// captureLoop reads PCM from src and hands whole frames to the session.
// Returns when ctx is done or src is exhausted.
func captureLoop(ctx context.Context, src io.Reader, sess *session) error {
br := bufio.NewReaderSize(src, defaultReadSize)
frameBytes := vad.FrameSamples() * 2 // 480 samples × 2 bytes = 960 bytes per 30ms
frameBytes := sess.vad.FrameSamples() * 2 // 480 samples × 2 bytes = 960 bytes per 30ms
log.Printf("mavwaked: capture loop starting (frame=%d bytes, %dms)",
frameBytes, defaultFrameMs)
@@ -147,7 +164,7 @@ func captureLoop(ctx context.Context, src io.Reader, vad *VAD, vc *voice.Client,
// Flush partial frame.
partial = append(partial, buf[:n]...)
if len(partial) >= frameBytes {
if err := processFrame(partial[:frameBytes], vad, vc, lang); err != nil {
if err := sess.feed(ctx, partial[:frameBytes]); err != nil {
log.Printf("mavwaked: process frame: %v", err)
}
partial = partial[frameBytes:]
@@ -165,107 +182,31 @@ func captureLoop(ctx context.Context, src io.Reader, vad *VAD, vc *voice.Client,
partial = nil
}
if err := processFrame(full, vad, vc, lang); err != nil {
if err := sess.feed(ctx, full); err != nil {
log.Printf("mavwaked: process frame: %v", err)
}
}
}
// processFrame feeds one 30ms PCM frame to the VAD and sends any completed
// utterance to the voice server.
func processFrame(frame []byte, vad *VAD, vc *voice.Client, lang string) error {
samples := PCMToI16(frame)
utt, state := vad.Feed(samples)
// voiceSender is the production utteranceSender: one PushToTalk round-trip
// over the voice wire. SurfaceVoice (not the default SurfacePCClient that
// c.PushToTalk uses) caps everything at L0, which is what makes an accidental
// VAD trigger safe.
type voiceSender struct{ vc *voice.Client }
if state == StateSpeech {
// Speech is in progress; nothing to send yet.
return nil
}
if utt.Bytes == nil {
// Still in silence, or short speech that didn't trigger.
return nil
}
// We have a complete utterance — send it to the voice server.
return sendUtterance(context.Background(), utt, vc, lang)
}
// sendUtterance sends audio to the voice server and plays the reply.
func sendUtterance(ctx context.Context, utt audio.Audio, vc *voice.Client, lang string) error {
dur := utt.Duration()
log.Printf("mavwaked: utterance complete (%.2fs, %d bytes), sending...",
dur, len(utt.Bytes))
// Use SendRequest directly so we can set SurfaceVoice instead of the
// default SurfacePCClient that c.PushToTalk uses.
func (s *voiceSender) Send(ctx context.Context, utt audio.Audio, lang string) (audio.Audio, error) {
var resp voice.PushToTalkResp
err := vc.SendRequest(ctx, voice.MethodPushToTalk, voice.PushToTalkReq{
err := s.vc.SendRequest(ctx, voice.MethodPushToTalk, voice.PushToTalkReq{
Audio: utt,
Lang: lang,
Surface: voice.SurfaceVoice,
}, &resp)
if err != nil {
return fmt.Errorf("push-to-talk: %w", err)
return audio.Audio{}, fmt.Errorf("push-to-talk: %w", err)
}
log.Printf("mavwaked: reply: %q (%.2fs audio)", resp.ReplyText, resp.ReplyAudio.Duration())
// Play the reply audio.
if len(resp.ReplyAudio.Bytes) > 0 {
go playAudio(resp.ReplyAudio)
} else {
log.Printf("mavwaked: empty reply audio (text only)")
}
if len(resp.RoutedChannels) > 0 {
log.Printf("mavwaked: also routed to: %v", resp.RoutedChannels)
}
return nil
}
// playAudio pipes PCM audio to aplay(1) for playback. Runs in a goroutine.
func playAudio(a audio.Audio) {
// Build WAV header for aplay (or pipe raw PCM with the right format flags).
cmd := exec.Command("aplay",
"-f", "S16_LE",
"-r", fmt.Sprintf("%d", a.Format.SampleRate),
"-c", fmt.Sprintf("%d", a.Format.Channels),
"-t", "raw",
)
stdin, err := cmd.StdinPipe()
if err != nil {
log.Printf("mavwaked: aplay stdin pipe: %v", err)
return
}
if err := cmd.Start(); err != nil {
log.Printf("mavwaked: start aplay: %v", err)
return
}
// Write audio to aplay's stdin.
if _, err := stdin.Write(a.Bytes); err != nil {
log.Printf("mavwaked: write to aplay: %v", err)
}
_ = stdin.Close()
// Wait for playback to finish (with a timeout).
done := make(chan error, 1)
go func() {
done <- cmd.Wait()
}()
select {
case err := <-done:
if err != nil {
log.Printf("mavwaked: aplay: %v", err)
}
case <-time.After(30 * time.Second):
log.Printf("mavwaked: aplay timeout, killing")
_ = cmd.Process.Kill()
<-done
}
return resp.ReplyAudio, nil
}
+139
View File
@@ -0,0 +1,139 @@
package main
// Reply playback, and the half-duplex gate around it (Vikunja #287).
//
// Before this, playback was `go playAudio(reply)` — fire and forget, with no
// handle on the running aplay. Two things fell out of that, and both are
// audible:
//
// 1. Self-trigger. The capture loop keeps feeding the VAD while the speaker
// is playing, so Maven's own reply comes back in through the mic, trips
// the VAD, and is sent to the daemon as a fresh utterance. She answers
// herself. There is no acoustic echo canceller in this pipeline, so the
// only correct fix is half-duplex: while she is speaking, the capture
// side is muted.
//
// 2. No barge-in. Talking over her did nothing — there was nothing to
// cancel, because nobody held the process handle.
//
// The two are the same mechanism seen from opposite sides, so they live
// together here. Echo suppression is unconditional (it fixes a bug). Barge-in
// is off unless -barge-in is passed, because it needs a room-specific energy
// threshold: with no echo canceller, the only way to tell "he is talking over
// her" from "the mic is hearing her" is that he is louder, and how much
// louder depends on where the mic sits relative to the speaker.
import (
"log"
"os/exec"
"strconv"
"sync"
"time"
"github.com/kami/maven/internal/audio"
)
// player plays one reply at a time and can be cut off mid-utterance.
type player interface {
// Play starts playback of a, replacing anything already playing, and
// returns immediately.
Play(a audio.Audio)
// Stop ends playback now. A no-op when nothing is playing.
Stop()
// Playing reports whether audio is currently going out of the speaker.
Playing() bool
}
// aplayPlayer pipes raw PCM to aplay(1). Stop kills the child, which is what
// makes barge-in instant rather than "instant at the end of the sentence".
type aplayPlayer struct {
mu sync.Mutex
cmd *exec.Cmd
playing bool
// gen rises on every Play/Stop so a finishing playback cannot clear the
// playing flag of the one that replaced it.
gen uint64
}
func newAplayPlayer() *aplayPlayer { return &aplayPlayer{} }
func (p *aplayPlayer) Play(a audio.Audio) {
if len(a.Bytes) == 0 {
return
}
p.Stop()
cmd := exec.Command("aplay",
"-f", "S16_LE",
"-r", strconv.Itoa(a.Format.SampleRate),
"-c", strconv.Itoa(a.Format.Channels),
"-t", "raw",
)
stdin, err := cmd.StdinPipe()
if err != nil {
log.Printf("mavwaked: aplay stdin pipe: %v", err)
return
}
if err := cmd.Start(); err != nil {
log.Printf("mavwaked: start aplay: %v", err)
_ = stdin.Close()
return
}
p.mu.Lock()
p.gen++
gen := p.gen
p.cmd = cmd
p.playing = true
p.mu.Unlock()
go func() {
if _, err := stdin.Write(a.Bytes); err != nil {
// Broken pipe is the expected outcome of Stop().
log.Printf("mavwaked: write to aplay: %v", err)
}
_ = stdin.Close()
done := make(chan error, 1)
go func() { done <- cmd.Wait() }()
select {
case err := <-done:
if err != nil {
log.Printf("mavwaked: aplay: %v", err)
}
case <-time.After(30 * time.Second):
log.Printf("mavwaked: aplay timeout, killing")
if pr := cmd.Process; pr != nil {
_ = pr.Kill()
}
<-done
}
p.mu.Lock()
if p.gen == gen {
p.playing = false
p.cmd = nil
}
p.mu.Unlock()
}()
}
func (p *aplayPlayer) Stop() {
p.mu.Lock()
cmd := p.cmd
if cmd != nil {
p.gen++
p.playing = false
p.cmd = nil
}
p.mu.Unlock()
if cmd != nil && cmd.Process != nil {
_ = cmd.Process.Kill()
}
}
func (p *aplayPlayer) Playing() bool {
p.mu.Lock()
defer p.mu.Unlock()
return p.playing
}
+33
View File
@@ -0,0 +1,33 @@
package main
import (
"testing"
"github.com/kami/maven/internal/audio"
)
// The real player must be safe to poke when nothing is playing — the capture
// loop calls Playing() on every 30ms frame, and Stop() lands on an idle
// player whenever a barge-in races the end of a reply. Neither may need
// aplay(1) to be installed.
func TestAplayPlayerIdleIsSafe(t *testing.T) {
p := newAplayPlayer()
if p.Playing() {
t.Fatal("a fresh player reports playing")
}
p.Stop()
p.Stop()
if p.Playing() {
t.Fatal("playing after Stop on an idle player")
}
// Empty audio is a text-only turn: nothing to play, no process to spawn.
p.Play(audio.Audio{Format: audio.PCM16kMono})
if p.Playing() {
t.Fatal("empty audio started playback")
}
}
func TestAplayPlayerSatisfiesPlayer(t *testing.T) {
var _ player = newAplayPlayer()
var _ player = &fakePlayer{}
}
+123
View File
@@ -0,0 +1,123 @@
package main
// The capture session: what happens to one 30ms frame, given whether Maven is
// currently speaking. Split out of main.go's processFrame so the decision is
// testable without a mic, a speaker, or a daemon (Vikunja #287).
import (
"context"
"log"
"github.com/kami/maven/internal/audio"
)
// utteranceSender ships one complete utterance to the voice server and
// returns the reply audio to play. The real one round-trips over the voice
// wire; tests substitute a recorder.
type utteranceSender interface {
Send(ctx context.Context, utt audio.Audio, lang string) (audio.Audio, error)
}
// bargeInConfig holds the two numbers barge-in needs. Zero Frames disables
// barge-in entirely — the half-duplex gate still runs.
type bargeInConfig struct {
// RMS is the normalised energy a frame must exceed to count as him
// talking over her rather than the mic hearing her. It is deliberately
// far above the VAD's own floor: the speaker leaks into the mic at
// roughly ambient level, a person talking at the mic does not.
RMS float64
// Frames is how many consecutive frames must clear RMS before playback
// is cut. One loud frame is a door closing; five in a row is a voice.
Frames int
}
// Enabled reports whether barge-in should be attempted at all.
func (c bargeInConfig) Enabled() bool { return c.Frames > 0 && c.RMS > 0 }
// session is the per-client capture state machine.
type session struct {
vad *VAD
player player
sender utteranceSender
lang string
barge bargeInConfig
// loudFrames counts consecutive over-threshold frames seen while she is
// speaking. Reset whenever a frame falls back under the threshold, and
// whenever playback ends.
loudFrames int
// counters, read by tests and logged on the way out.
suppressed int // frames dropped because she was speaking
bargeIns int // times playback was cut because he spoke over her
sent int // utterances shipped to the daemon
}
func newSession(vad *VAD, p player, s utteranceSender, lang string, barge bargeInConfig) *session {
return &session{vad: vad, player: p, sender: s, lang: lang, barge: barge}
}
// feed processes one 30ms PCM frame.
//
// While the player is running the capture side is muted: the VAD is not fed
// and no utterance can be produced, so Maven's own reply cannot come back in
// as a new command. The one thing that gets through is barge-in — sustained
// energy well above the speaker's leak level cuts playback, and capture
// resumes on the very next frame with a clean VAD.
func (s *session) feed(ctx context.Context, frame []byte) error {
if s.player.Playing() {
s.suppressed++
if !s.barge.Enabled() {
return nil
}
if frameRMS(PCMToI16(frame)) < s.barge.RMS {
s.loudFrames = 0
return nil
}
s.loudFrames++
if s.loudFrames < s.barge.Frames {
return nil
}
// He is talking over her. Cut her off, drop the VAD state that
// accumulated from the echo, and start listening for real.
s.player.Stop()
s.bargeIns++
s.loudFrames = 0
s.vad.Reset()
log.Printf("mavwaked: barge-in — stopped playback")
return nil
}
// Not speaking. If we just stopped, make sure no echo-era state leaks
// into the next utterance.
if s.loudFrames != 0 {
s.loudFrames = 0
s.vad.Reset()
}
utt, state := s.vad.Feed(PCMToI16(frame))
if state == StateSpeech || utt.Bytes == nil {
return nil
}
return s.dispatch(ctx, utt)
}
// dispatch ships a complete utterance and plays whatever comes back.
func (s *session) dispatch(ctx context.Context, utt audio.Audio) error {
log.Printf("mavwaked: utterance complete (%.2fs, %d bytes), sending...", utt.Duration(), len(utt.Bytes))
reply, err := s.sender.Send(ctx, utt, s.lang)
s.sent++
if err != nil {
return err
}
if len(reply.Bytes) == 0 {
log.Printf("mavwaked: empty reply audio (text only)")
return nil
}
// The VAD has been accumulating from the buffered mic stream while the
// round-trip blocked. None of it is a command — reset before the
// speaker opens, so the first post-reply frame starts clean.
s.vad.Reset()
s.player.Play(reply)
return nil
}
+282
View File
@@ -0,0 +1,282 @@
package main
import (
"context"
"errors"
"math"
"testing"
"github.com/kami/maven/internal/audio"
)
// fakePlayer records Play/Stop instead of shelling out to aplay.
type fakePlayer struct {
playing bool
plays int
stops int
last audio.Audio
}
func (p *fakePlayer) Play(a audio.Audio) { p.playing = true; p.plays++; p.last = a }
func (p *fakePlayer) Stop() { p.playing = false; p.stops++ }
func (p *fakePlayer) Playing() bool { return p.playing }
// fakeSender records what was shipped and hands back a canned reply.
type fakeSender struct {
sent []audio.Audio
reply audio.Audio
err error
}
func (s *fakeSender) Send(_ context.Context, utt audio.Audio, _ string) (audio.Audio, error) {
s.sent = append(s.sent, utt)
return s.reply, s.err
}
func replyAudio() audio.Audio {
return audio.Audio{Format: audio.PCM16kMono, Bytes: make([]byte, 16000)}
}
// frameAt returns a 30ms frame whose RMS is approximately rms.
func frameAt(rms float64) []byte {
amp := rms * math.Sqrt2 * 32768
f := make([]int16, frameSamples)
for i := range f {
f[i] = int16(amp * math.Sin(2*math.Pi*440*float64(i)/16000))
}
return pcmBytes(f)
}
func silentBytes() []byte { return make([]byte, frameSamples*2) }
// newTestSession wires a session with fakes and a default VAD.
func newTestSession(barge bargeInConfig) (*session, *fakePlayer, *fakeSender) {
p := &fakePlayer{}
s := &fakeSender{reply: replyAudio()}
return newSession(NewVAD(0, 0, 0, 0), p, s, "ru", barge), p, s
}
// speakThenPause drives a full utterance through the session: enough loud
// frames to trigger, then enough silence to end it.
func speakThenPause(t *testing.T, sess *session) {
t.Helper()
speechFrames := (defaultSpeechMs + defaultFrameMs - 1) / defaultFrameMs
silenceFrames := (defaultSilenceMs+defaultFrameMs-1)/defaultFrameMs + 2
loud := frameAt(0.35)
for i := 0; i < speechFrames+5; i++ {
if err := sess.feed(context.Background(), loud); err != nil {
t.Fatalf("feed loud frame %d: %v", i, err)
}
}
for i := 0; i < silenceFrames; i++ {
if err := sess.feed(context.Background(), silentBytes()); err != nil {
t.Fatalf("feed silent frame %d: %v", i, err)
}
}
}
func TestSessionSendsUtteranceAndPlaysReply(t *testing.T) {
sess, p, snd := newTestSession(bargeInConfig{})
speakThenPause(t, sess)
if len(snd.sent) != 1 {
t.Fatalf("sent %d utterances, want 1", len(snd.sent))
}
if snd.sent[0].Format != audio.PCM16kMono {
t.Errorf("utterance format = %+v, want canonical", snd.sent[0].Format)
}
if p.plays != 1 {
t.Errorf("plays = %d, want 1", p.plays)
}
}
// The bug this whole file exists for: while the speaker is running, the mic
// hears Maven and the old code shipped that back as a fresh command.
func TestSessionDoesNotHearItselfWhilePlaying(t *testing.T) {
sess, p, snd := newTestSession(bargeInConfig{})
speakThenPause(t, sess)
if !p.Playing() {
t.Fatal("expected playback to be running after the reply")
}
// Feed a long stretch of loud audio — Maven's own voice coming back in.
base := sess.suppressed
loud := frameAt(0.35)
for i := 0; i < 200; i++ {
if err := sess.feed(context.Background(), loud); err != nil {
t.Fatalf("feed echo frame %d: %v", i, err)
}
}
if len(snd.sent) != 1 {
t.Fatalf("sent %d utterances, want 1 — her own reply was captured as a command", len(snd.sent))
}
if got := sess.suppressed - base; got != 200 {
t.Errorf("suppressed %d of the 200 echo frames, want all of them", got)
}
if p.stops != 0 {
t.Errorf("stops = %d, want 0 — barge-in is off, nothing should cut her off", p.stops)
}
}
// With barge-in off, no amount of noise stops playback.
func TestSessionBargeInDisabledByDefault(t *testing.T) {
sess, p, _ := newTestSession(bargeInConfig{})
if sess.barge.Enabled() {
t.Fatal("zero bargeInConfig must be disabled")
}
speakThenPause(t, sess)
veryLoud := frameAt(0.6)
for i := 0; i < 50; i++ {
_ = sess.feed(context.Background(), veryLoud)
}
if p.stops != 0 || sess.bargeIns != 0 {
t.Fatalf("stops = %d, bargeIns = %d, want 0 with barge-in off", p.stops, sess.bargeIns)
}
}
func TestSessionBargeInCutsPlayback(t *testing.T) {
barge := bargeInConfig{RMS: 0.12, Frames: 5}
sess, p, _ := newTestSession(barge)
speakThenPause(t, sess)
if !p.Playing() {
t.Fatal("expected playback after the reply")
}
// Four loud frames must not be enough — a door closing is not a voice.
veryLoud := frameAt(0.35)
for i := 0; i < 4; i++ {
_ = sess.feed(context.Background(), veryLoud)
}
if p.stops != 0 {
t.Fatalf("playback cut after 4 frames, want it to hold until %d", barge.Frames)
}
// The fifth cuts her off.
_ = sess.feed(context.Background(), veryLoud)
if p.stops != 1 || sess.bargeIns != 1 {
t.Fatalf("stops = %d, bargeIns = %d, want 1 and 1", p.stops, sess.bargeIns)
}
if p.Playing() {
t.Fatal("still playing after barge-in")
}
}
// A burst that falls back under the threshold resets the counter, so noise
// spread over a whole reply never accumulates into a false barge-in.
func TestSessionBargeInNeedsConsecutiveFrames(t *testing.T) {
sess, p, _ := newTestSession(bargeInConfig{RMS: 0.12, Frames: 5})
speakThenPause(t, sess)
veryLoud := frameAt(0.35)
quiet := frameAt(0.02)
for i := 0; i < 20; i++ {
_ = sess.feed(context.Background(), veryLoud)
_ = sess.feed(context.Background(), veryLoud)
_ = sess.feed(context.Background(), quiet)
}
if p.stops != 0 || sess.bargeIns != 0 {
t.Fatalf("stops = %d, bargeIns = %d, want 0 — two-frame bursts must not accumulate", p.stops, sess.bargeIns)
}
}
// Speaker leak sits near the room floor; it must never reach the barge-in bar.
func TestSessionEchoLevelAudioNeverBargesIn(t *testing.T) {
sess, p, _ := newTestSession(bargeInConfig{RMS: 0.12, Frames: 5})
speakThenPause(t, sess)
base := sess.suppressed
leak := frameAt(0.05) // loud enough for the VAD, far under the barge bar
for i := 0; i < 300; i++ {
_ = sess.feed(context.Background(), leak)
}
if p.stops != 0 {
t.Fatalf("stops = %d, want 0 — speaker leak must not read as barge-in", p.stops)
}
if got := sess.suppressed - base; got != 300 {
t.Errorf("suppressed %d of the 300 leak frames, want all of them", got)
}
}
// After barge-in the VAD must start clean, so the interrupting speech is
// captured as a whole utterance rather than joined onto echo state.
func TestSessionCapturesTheInterruptingUtterance(t *testing.T) {
sess, p, snd := newTestSession(bargeInConfig{RMS: 0.12, Frames: 5})
speakThenPause(t, sess)
veryLoud := frameAt(0.35)
for i := 0; i < 5; i++ {
_ = sess.feed(context.Background(), veryLoud)
}
if p.stops != 1 {
t.Fatalf("expected barge-in, stops = %d", p.stops)
}
// He keeps talking; that is a new command.
speakThenPause(t, sess)
if len(snd.sent) != 2 {
t.Fatalf("sent %d utterances, want 2 — the interruption itself must be heard", len(snd.sent))
}
if p.plays != 2 {
t.Errorf("plays = %d, want 2", p.plays)
}
}
// A failed round-trip must surface as an error and must not start playback.
func TestSessionSendErrorDoesNotPlay(t *testing.T) {
p := &fakePlayer{}
snd := &fakeSender{err: errors.New("boom")}
sess := newSession(NewVAD(0, 0, 0, 0), p, snd, "ru", bargeInConfig{})
speechFrames := (defaultSpeechMs + defaultFrameMs - 1) / defaultFrameMs
silenceFrames := (defaultSilenceMs+defaultFrameMs-1)/defaultFrameMs + 2
loud := frameAt(0.35)
var lastErr error
for i := 0; i < speechFrames+5; i++ {
_ = sess.feed(context.Background(), loud)
}
for i := 0; i < silenceFrames; i++ {
if err := sess.feed(context.Background(), silentBytes()); err != nil {
lastErr = err
}
}
if lastErr == nil {
t.Fatal("send error was swallowed")
}
if p.plays != 0 || p.Playing() {
t.Fatalf("plays = %d, playing = %v, want no playback on a failed round-trip", p.plays, p.Playing())
}
}
// An empty reply (text-only turn) must leave the capture side open.
func TestSessionEmptyReplyLeavesCaptureOpen(t *testing.T) {
p := &fakePlayer{}
snd := &fakeSender{reply: audio.Audio{Format: audio.PCM16kMono}}
sess := newSession(NewVAD(0, 0, 0, 0), p, snd, "ru", bargeInConfig{})
speakThenPause(t, sess)
if p.plays != 0 {
t.Fatalf("plays = %d, want 0 for an empty reply", p.plays)
}
speakThenPause(t, sess)
if len(snd.sent) != 2 {
t.Fatalf("sent %d, want 2 — capture must stay open when there is no audio reply", len(snd.sent))
}
}
func TestBargeInConfigEnabled(t *testing.T) {
cases := []struct {
c bargeInConfig
want bool
}{
{bargeInConfig{}, false},
{bargeInConfig{RMS: 0.12}, false},
{bargeInConfig{Frames: 5}, false},
{bargeInConfig{RMS: 0.12, Frames: 5}, true},
}
for _, tc := range cases {
if got := tc.c.Enabled(); got != tc.want {
t.Errorf("%+v.Enabled() = %v, want %v", tc.c, got, tc.want)
}
}
}
+9
View File
@@ -23,6 +23,15 @@ const (
defaultSilenceMs = 800 // silence hold before declaring end-of-utterance
defaultMaxMs = 10000 // cap single utterance at 10s
defaultMinRMS = 0.01 // RMS floor (same as mavsttd)
// Barge-in thresholds. Only used when -barge-in is passed. The RMS is
// x10000 like -min-rms, and sits an order of magnitude above the VAD's
// own floor on purpose: with no acoustic echo canceller, a frame only
// counts as "he is talking over her" if it is far louder than what the
// speaker leaks back into the mic. 5 frames is 150ms — long enough that
// a door or a cough does not cut her off mid-sentence.
defaultBargeRMS = 1200 // 0.12 normalised RMS
defaultBargeFrames = 5
)
// frameSamples — samples per 30ms frame at 16kHz.
+24
View File
@@ -0,0 +1,24 @@
{{template "shellTop" "events"}}
<h1>Intake</h1>
<div class=hint>Everything that arrived, newest first — a relayed notification, a mail candidate, a feed
item, a changed page, a spend, a presence probe. One envelope per write; the durable row is still the
fact, note or task itself. Held in memory only, so a restart empties this.</div>
{{if .Err}}<div class=hint>journal unavailable: {{.Err}}</div>{{end}}
{{if and (not .Events) (not .Err)}}
<div class=hint>nothing has arrived yet</div>
{{end}}
{{if .Events}}
<div class=scroll><table class=mono>
<tr><th>when<th>source<th>kind<th>pri<th>what<th>detail</tr>
{{range .Events}}<tr>
<td>{{.OccurredAt.Format "02.01 15:04:05"}}</td>
<td class=gray>{{.Source}}</td>
<td class=gray>{{.Kind}}</td>
<td class=gray>{{.Priority}}</td>
<td>{{.Title}}</td>
<td class=gray>{{.Body}}</td>
</tr>{{end}}
</table></div>
{{end}}
{{template "shellBottom"}}
</html>
+107
View File
@@ -0,0 +1,107 @@
package main
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/kami/maven/internal/ipc"
)
// eventsCore serves a canned intake journal. Embedding
// ipc.UnimplementedCoreAPI means any other call fails loudly.
type eventsCore struct {
ipc.UnimplementedCoreAPI
events []ipc.IntakeEvent
err error
gotN int
}
func (c *eventsCore) RecentEvents(_ context.Context, n int) ([]ipc.IntakeEvent, error) {
c.gotN = n
return c.events, c.err
}
func getEvents(t *testing.T, core ipc.CoreAPI) *httptest.ResponseRecorder {
t.Helper()
w := httptest.NewRecorder()
handleEvents(w, httptest.NewRequest(http.MethodGet, "/events", nil), core)
return w
}
func TestEventsPageRendersTheJournal(t *testing.T) {
core := &eventsCore{events: []ipc.IntakeEvent{
{Source: "rss:tech", Kind: "note", Title: "Вышло ядро 6.19", Priority: "low",
OccurredAt: time.Date(2026, 8, 1, 7, 15, 0, 0, time.UTC)},
{Source: "ambient:notif", Kind: "fact", Title: "calendar_event_20260801_планёрка",
Body: "10:00-11:00 планёрка", Priority: "low",
OccurredAt: time.Date(2026, 8, 1, 10, 0, 0, 0, time.UTC)},
}}
w := getEvents(t, core)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", w.Code)
}
body := w.Body.String()
for _, want := range []string{"rss:tech", "Вышло ядро 6.19", "ambient:notif", "10:00-11:00 планёрка", "01.08 10:00:00"} {
if !strings.Contains(body, want) {
t.Errorf("page does not mention %q", want)
}
}
if core.gotN != eventsPageLimit {
t.Errorf("asked core for %d events, want %d", core.gotN, eventsPageLimit)
}
}
func TestEventsPageSaysNothingArrived(t *testing.T) {
w := getEvents(t, &eventsCore{})
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", w.Code)
}
if !strings.Contains(w.Body.String(), "nothing has arrived yet") {
t.Error("empty journal did not render the empty-state line")
}
}
func TestEventsPageReportsAReadFailure(t *testing.T) {
// An unreachable journal must say so rather than render an empty table,
// which would imply nothing arrived.
w := getEvents(t, &eventsCore{err: errors.New("core is down")})
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 with the error rendered", w.Code)
}
body := w.Body.String()
if !strings.Contains(body, "journal unavailable") || !strings.Contains(body, "core is down") {
t.Errorf("page did not report the read failure: %s", body)
}
if strings.Contains(body, "nothing has arrived yet") {
t.Error("a failed read rendered as an empty journal")
}
}
func TestEventsPageWithoutCore(t *testing.T) {
w := getEvents(t, nil)
if w.Code != http.StatusServiceUnavailable {
t.Errorf("status = %d, want 503", w.Code)
}
}
func TestEventsPageEscapesIntakeText(t *testing.T) {
// Titles come from outside — a feed headline, a notification. They are shown
// on a page and must never be able to inject markup into it.
core := &eventsCore{events: []ipc.IntakeEvent{{
Source: "rss:x", Kind: "note", Priority: "low",
Title: `<script>alert(1)</script>`,
OccurredAt: time.Date(2026, 8, 1, 7, 0, 0, 0, time.UTC),
}}}
body := getEvents(t, core).Body.String()
if strings.Contains(body, "<script>alert(1)</script>") {
t.Error("intake title was not escaped")
}
if !strings.Contains(body, "&lt;script&gt;") {
t.Error("intake title is missing from the page entirely")
}
}
+54
View File
@@ -67,6 +67,14 @@ type fakeCore struct {
// for handleChatAPI tests
chatText string
chatErr error
// for the MCP section of /tools
mcpServers []ipc.MCPServerStatus
mcpErr error
}
func (f *fakeCore) MCPServers(context.Context) ([]ipc.MCPServerStatus, error) {
return f.mcpServers, f.mcpErr
}
func (f *fakeCore) Chat(_ context.Context, text string) (string, error) {
@@ -1118,3 +1126,49 @@ func TestHandleChatAPI_FailOpenByDefault(t *testing.T) {
t.Errorf("core.Chat text = %q, want %q", core.chatText, "привет")
}
}
// The MCP section renders the configured servers, and a proposal that already
// knows its cmd prefills the enable form so the argv is not retyped by hand.
func TestHandleTools_GET_MCPSection(t *testing.T) {
core := &fakeCore{
proposed: []ipc.Tool{{
Name: "vikunja_list_tasks", Scope: "mcp:vikunja",
Cmd: []string{"mcp", "vikunja", "list_tasks"}, Destructive: true,
Utterance: "mcp vikunja/list_tasks: List tasks in a project.",
}},
mcpServers: []ipc.MCPServerStatus{
{Name: "vikunja", Transport: "http", Target: "http://192.168.1.104:9100/mcp", Connected: true, Server: "vikunja 0.1.0", Tools: 4},
{Name: "files", Transport: "stdio", Target: "mcp-server-fs /srv", Err: "start: no such file"},
},
}
rr := httptest.NewRecorder()
handleTools(rr, httptest.NewRequest(http.MethodGet, "/tools", nil), core, nil, false)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d", rr.Code)
}
body := rr.Body.String()
for _, want := range []string{
"MCP servers", "vikunja", "192.168.1.104:9100/mcp", "vikunja 0.1.0",
"files", "no such file",
`value="mcp vikunja list_tasks"`, // the enable form is prefilled
"checked", // and pre-marked destructive (no readOnlyHint)
} {
if !strings.Contains(body, want) {
t.Errorf("missing %q in /tools output", want)
}
}
}
// MCP off (or an older core that does not know the method) renders the section
// empty instead of breaking the page.
func TestHandleTools_GET_MCPUnavailable(t *testing.T) {
core := &fakeCore{mcpErr: ipc.ErrNotImplemented}
rr := httptest.NewRecorder()
handleTools(rr, httptest.NewRequest(http.MethodGet, "/tools", nil), core, nil, false)
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rr.Code)
}
if !strings.Contains(rr.Body.String(), "no MCP servers configured") {
t.Error("expected the empty-state copy")
}
}
+78 -4
View File
@@ -71,6 +71,9 @@ var ecosystemHTML string
//go:embed morning.html
var morningHTML string
//go:embed events.html
var eventsHTML string
// ── Ethos Workstation Shell ──
//
// Two template pieces that wrap every page:
@@ -105,6 +108,7 @@ var sidebarSections = []struct {
{Label: "Reminders", URL: "/reminders", Key: "reminders"},
{Label: "Routines", URL: "/routines", Key: "routines"},
{Label: "Morning", URL: "/morning", Key: "morning"},
{Label: "Intake", URL: "/events", Key: "events"},
},
},
{
@@ -124,6 +128,7 @@ var sidebarSections = []struct {
Label: "Settings",
Pages: []struct{ Label, URL, Key string }{
{Label: "Tools", URL: "/tools", Key: "tools"},
{Label: "Model", URL: "/models", Key: "models"},
{Label: "Passkey", URL: "/auth/passkey", Key: "passkey"},
},
},
@@ -191,6 +196,8 @@ func pageIcon(key string) string {
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-grid"/></svg>`
case "tools":
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-settings"/></svg>`
case "models":
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-wave"/></svg>`
case "passkey":
return `<svg class=icon width="14" height="14"><use href="/ethos-icons.svg#i-lock"/></svg>`
default:
@@ -225,6 +232,8 @@ func pageTitle(key string) string {
return "Ecosystem"
case "tools":
return "Tools"
case "models":
return "Resident Model"
case "passkey":
return "Passkey"
default:
@@ -313,6 +322,10 @@ var ecosystemTmpl = template.Must(template.New("ecosystem").Funcs(shellFuncs()).
// morning routine (internal/morning). Same shape as trace.html: a plain
// server-rendered page, refreshed on reload — no live-update loop, since
// checklist state changes on the scale of minutes, not seconds.
// eventsTmpl — the unified intake journal (Vikunja #283), read-only. Same
// shape as trace.html and morning.html: server-rendered, refreshed on reload.
var eventsTmpl = template.Must(template.New("events").Funcs(shellFuncs()).Parse(shellTopHTML + eventsHTML + shellBottomHTML))
var morningTmpl = template.Must(template.New("morning").Funcs(shellFuncs()).Parse(shellTopHTML + morningHTML + shellBottomHTML))
func noCache(h http.Handler) http.Handler {
@@ -425,6 +438,9 @@ func main() {
mux.HandleFunc("/morning", func(w http.ResponseWriter, r *http.Request) {
handleMorning(w, r, core)
})
mux.HandleFunc("/events", func(w http.ResponseWriter, r *http.Request) {
handleEvents(w, r, core)
})
ecoURLsCfg := ecoURLs{nexus: *nexusURL, praxis: *praxisURL, hexis: *hexisURL}
mux.HandleFunc("/ecosystem", func(w http.ResponseWriter, r *http.Request) {
handleEcosystem(w, r, ecoURLsCfg)
@@ -479,6 +495,12 @@ func main() {
mux.HandleFunc("/routines", func(w http.ResponseWriter, r *http.Request) {
handleRoutines(w, r, core, stepUpSession, *requireStepUp)
})
// /models — the resident-model surface (Vikunja #250). Same step-up gate as
// /tools, and for a comparable reason: which model is loaded decides how every
// utterance is routed and how every reply is worded. GET is read-only.
mux.HandleFunc("/models", func(w http.ResponseWriter, r *http.Request) {
handleModels(w, r, core, stepUpSession, *requireStepUp)
})
// State-changing routes on this server, and their gate (Vikunja #317):
//
@@ -681,7 +703,7 @@ const toolsHTML = `{{template "shellTop" "tools"}}
{{if .Msg}}<div class="msg msg-ok">{{.Msg}}</div>{{end}}
<section class=card>
<h2 class=card-title>proposed <span class=badge>{{len .Proposed}}</span></h2>
{{if .Proposed}}<p class=hint>maven drafted these from acts she couldn't run. Fill the command (argv, space-separated) and enable.</p>
{{if .Proposed}}<p class=hint>maven drafted these from acts she couldn't run. Fill the command (argv, space-separated) and enable. A row in an <code>mcp:</code> scope came from an MCP server and already knows what it calls — check the command, then enable.</p>
<div class=scroll><table><tr><th>name</th><th>scope</th><th>from utterance</th><th>enable as</th></tr>
{{range .Proposed}}<tr>
<td><code>{{.Name}}</code></td><td><span class=badge>{{.Scope}}</span></td><td>{{.Utterance}}</td>
@@ -689,8 +711,8 @@ const toolsHTML = `{{template "shellTop" "tools"}}
<input type=hidden name=name value="{{.Name}}">
<input type=hidden name=scope value="{{.Scope}}">
<input type=hidden name=action value=enable>
<input type=text name=cmd class=input-wide placeholder="systemctl restart" required>
<label><input type=checkbox name=destructive> destructive</label>
<input type=text name=cmd class=input-wide placeholder="systemctl restart" value="{{join .Cmd " "}}" required>
<label><input type=checkbox name=destructive {{if .Destructive}}checked{{end}}> destructive</label>
<button class=btn>enable</button></form>
<form method=post action=/tools class=inline-form>
<input type=hidden name=name value="{{.Name}}">
@@ -719,6 +741,19 @@ const toolsHTML = `{{template "shellTop" "tools"}}
<div class=hint>enable proposed tools above, or ask maven to configure one</div>
</div>{{end}}
</section>
<section class=card>
<h2 class=card-title>MCP servers <span class=badge>{{len .MCP}}</span></h2>
{{if .MCP}}<p class=hint>servers she connects OUT to. Their tools appear above as proposals — a configured server is a place she may look, not a capability she has. A <code>stdio</code> target is a process on this box; an <code>http</code> one on a loopback or LAN address is inside the network, so treat its tools accordingly.</p>
<div class=scroll><table><tr><th>name</th><th>transport</th><th>target</th><th>state</th><th>tools</th></tr>
{{range .MCP}}<tr><td><code>{{.Name}}</code></td><td><span class=badge>{{.Transport}}</span></td><td><code>{{.Target}}</code></td>
<td>{{if .Connected}}connected{{if .Server}}{{.Server}}{{end}}{{else}}<span class=red>down</span>{{if .Err}}{{.Err}}{{end}}{{end}}</td>
<td>{{.Tools}}</td></tr>{{end}}</table></div>
{{else}}<div class=empty>
<svg class=icon width="20" height="20"><use href="/ethos-icons.svg#i-settings"/></svg>
<div>no MCP servers configured</div>
<div class=hint>add an <code>mcp.servers</code> block to mavend.json to let her use an external tool server</div>
</div>{{end}}
</section>
{{template "shellBottom"}}`
// routinesHTML — proposed routine review surface. One row per thing maven
@@ -1182,6 +1217,37 @@ type morningView struct {
Routines []ipc.MorningRoutineStatus
}
// eventsView — what /events renders. Err is set instead of Events when the
// core could not serve the journal, so the page says why rather than showing an
// empty intake and implying nothing arrived.
type eventsView struct {
Events []ipc.IntakeEvent
Err string
}
// eventsPageLimit — how many envelopes the page shows. The ring holds more; a
// page is for scanning what just happened, not for archaeology.
const eventsPageLimit = 200
func handleEvents(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
if core == nil {
http.Error(w, "intake journal disabled (no -core)", http.StatusServiceUnavailable)
return
}
var view eventsView
evs, err := core.RecentEvents(r.Context(), eventsPageLimit)
if err != nil {
log.Printf("events: %v", err)
view.Err = err.Error()
} else {
view.Events = evs
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := eventsTmpl.Execute(w, view); err != nil {
log.Printf("events render: %v", err)
}
}
func handleVoice(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := voiceTmpl.Execute(w, nil); err != nil {
@@ -1309,12 +1375,20 @@ func handleTools(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, sessi
http.Error(w, "core read failed", http.StatusBadGateway)
return
}
// MCP is off by default and an older core may not know the method at all,
// so a failure here renders an empty section rather than breaking the page.
servers, err := core.MCPServers(ctx)
if err != nil {
log.Printf("tools: mcp servers: %v", err)
servers = nil
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := toolsTmpl.Execute(w, struct {
Msg string
Proposed []ipc.Tool
Enabled []ipc.Tool
}{msg, proposed, enabled}); err != nil {
MCP []ipc.MCPServerStatus
}{msg, proposed, enabled, servers}); err != nil {
log.Printf("tools render: %v", err)
}
}
+145
View File
@@ -0,0 +1,145 @@
package main
import (
"context"
"errors"
"html/template"
"log"
"net/http"
"strconv"
"strings"
"github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/webauthn"
)
// The resident-model surface (Vikunja #250).
//
// GET shows which model llama-server actually has loaded and which files the
// daemon is configured to allow. POST swaps to one of them, behind the same
// step-up gate as POST /tools: the loaded model decides how every utterance is
// routed and how every reply is worded, so it is an owner action.
//
// There is nothing on this page Maven can press. The swap is an IPC method rated
// AuthStepUp in internal/auth, unreachable from an act, an intent or a timer.
// modelController — the two non-CoreAPI methods this page needs. *ipc.Client
// satisfies it; a core without a swap allowlist answers ErrUnknownMethod, which
// the page renders as "not configured" rather than an error.
type modelController interface {
ModelStatus(ctx context.Context) (ipc.ModelStatusResp, error)
SwapModel(ctx context.Context, req ipc.SwapModelReq) (ipc.SwapModelResp, error)
}
var modelsTmpl = template.Must(template.New("models").Funcs(shellFuncs()).Parse(shellTopHTML + modelsHTML + shellBottomHTML))
const modelsHTML = `{{template "shellTop" "models"}}
<h1>Resident model</h1>
<p class=hint>swapping requires step-up — <a href=/auth/passkey>assert a passkey</a> first. The old model is unloaded before the new one is loaded (one model fits the iGPU at a time), so turns during the load are refused and fall back to the classifier.</p>
{{if .Msg}}<div class="msg msg-ok">{{.Msg}}</div>{{end}}
{{if .Err}}<div class="msg msg-err">{{.Err}}</div>{{end}}
{{if .Off}}
<section class=card>
<h2 class=card-title>swap not configured</h2>
<p class=hint>this core has no <code>phraser.swap_models</code> allowlist, so there is nothing to swap to. Add the gguf paths you allow to <code>deploy/mavend.json</code> and restart once.</p>
</section>
{{else}}
<section class=card>
<h2 class=card-title>loaded now</h2>
<div class=scroll><table>
<tr><th>model</th><td><code>{{.Status.Model}}</code></td></tr>
<tr><th>file</th><td><code>{{.Status.ModelPath}}</code></td></tr>
<tr><th>server</th><td><code>{{.Status.BaseURL}}</code></td></tr>
<tr><th>n_ctx</th><td>{{.Status.NCtx}}</td></tr>
<tr><th>n_gpu_layers</th><td>{{.Status.NGpuLayers}}</td></tr>
</table></div>
<p class=hint>the model name is what llama-server reports for itself, not what the config says it should be.</p>
</section>
<section class=card>
<h2 class=card-title>allowed models <span class=badge>{{len .Status.Swappable}}</span></h2>
{{if .Status.Swappable}}<div class=scroll><table><tr><th>file</th><th></th></tr>
{{range .Status.Swappable}}<tr><td><code>{{.}}</code></td>
<td><form method=post action=/models class=inline-form>
<input type=hidden name=model_path value="{{.}}">
<button class=btn>load this one</button></form></td></tr>{{end}}
</table></div>
{{else}}<div class=empty><div>no models allowlisted</div></div>{{end}}
</section>
{{end}}
{{template "shellBottom"}}`
type modelsPage struct {
Msg string
Err string
Off bool
Status ipc.ModelStatusResp
}
// handleModels renders the model surface (GET) and applies a swap (POST).
//
// A failed swap is reported as a failure with the model that is still serving
// named, because that is the state the operator needs: the daemon rolled back
// and is answering turns, it just is not answering them with what he asked for.
func handleModels(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, session *webauthn.PasskeySession, requireStepUp bool) {
if core == nil {
http.Error(w, "models disabled (no -core)", http.StatusServiceUnavailable)
return
}
mc, ok := core.(modelController)
if !ok {
http.Error(w, "models unavailable: core connection does not support model swap", http.StatusServiceUnavailable)
return
}
ctx := r.Context()
page := modelsPage{}
if r.Method == http.MethodPost {
if !stepUpOK(session, requireStepUp) {
http.Error(w, "step-up required: assert a passkey first", http.StatusForbidden)
return
}
path := strings.TrimSpace(r.FormValue("model_path"))
if path == "" {
http.Error(w, "model_path required", http.StatusBadRequest)
return
}
req := ipc.SwapModelReq{ModelPath: path}
if v, err := strconv.Atoi(r.FormValue("n_ctx")); err == nil {
req.NCtx = v
}
res, err := mc.SwapModel(ctx, req)
switch {
case err == nil:
page.Msg = "loaded " + res.Model + " (" + strconv.FormatInt(res.TookMs, 10) + "ms)"
log.Printf("models: swapped to %s (%s) in %dms", res.ModelPath, res.Model, res.TookMs)
case errors.Is(err, ipc.ErrForbidden):
http.Error(w, "refused: that model is not in phraser.swap_models, or step-up was not asserted", http.StatusForbidden)
return
case errors.Is(err, ipc.ErrUnknownMethod):
http.Error(w, "swap not configured on this core", http.StatusServiceUnavailable)
return
case res.RolledBack:
page.Err = "swap failed, rolled back to " + res.Model + " — she is still answering, with the old model"
log.Printf("models: swap to %s failed, rolled back: %v", path, err)
default:
page.Err = "swap failed: " + err.Error()
log.Printf("models: swap to %s failed: %v", path, err)
}
}
st, err := mc.ModelStatus(ctx)
if err != nil {
if errors.Is(err, ipc.ErrUnknownMethod) {
page.Off = true
} else {
log.Printf("models: status: %v", err)
http.Error(w, "core read failed", http.StatusBadGateway)
return
}
}
page.Status = st
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := modelsTmpl.Execute(w, page); err != nil {
log.Printf("models render: %v", err)
}
}
+150
View File
@@ -0,0 +1,150 @@
package main
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/webauthn"
)
// fakeModelCore is a core that supports the two model methods. It records what
// the page asked for, so the tests can assert the gate rather than the HTML.
type fakeModelCore struct {
ipc.UnimplementedCoreAPI
status ipc.ModelStatusResp
statusErr error
swapResp ipc.SwapModelResp
swapErr error
swapped []ipc.SwapModelReq
}
func (f *fakeModelCore) ModelStatus(ctx context.Context) (ipc.ModelStatusResp, error) {
return f.status, f.statusErr
}
func (f *fakeModelCore) SwapModel(ctx context.Context, req ipc.SwapModelReq) (ipc.SwapModelResp, error) {
f.swapped = append(f.swapped, req)
return f.swapResp, f.swapErr
}
func modelsGET(t *testing.T, core ipc.CoreAPI) *httptest.ResponseRecorder {
t.Helper()
w := httptest.NewRecorder()
handleModels(w, httptest.NewRequest(http.MethodGet, "/models", nil), core, nil, false)
return w
}
func modelsPOST(t *testing.T, core ipc.CoreAPI, session *webauthn.PasskeySession, requireStepUp bool, path string) *httptest.ResponseRecorder {
t.Helper()
r := httptest.NewRequest(http.MethodPost, "/models", strings.NewReader("model_path="+path))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
w := httptest.NewRecorder()
handleModels(w, r, core, session, requireStepUp)
return w
}
func TestModels_GETShowsTheLoadedModelAndTheAllowlist(t *testing.T) {
core := &fakeModelCore{status: ipc.ModelStatusResp{
Model: "Qwen3-1.7B-UD-Q4_K_XL",
ModelPath: "/opt/maven/models/llm/qwen3.gguf",
BaseURL: "http://127.0.0.1:18099",
NCtx: 4096,
Swappable: []string{"/opt/maven/models/llm/qwen3.gguf", "/opt/maven/models/llm/qwen3-cpt.gguf"},
}}
w := modelsGET(t, core)
if w.Code != http.StatusOK {
t.Fatalf("GET /models = %d; want 200", w.Code)
}
body := w.Body.String()
for _, want := range []string{"Qwen3-1.7B-UD-Q4_K_XL", "qwen3-cpt.gguf", "4096"} {
if !strings.Contains(body, want) {
t.Errorf("page does not mention %q", want)
}
}
if len(core.swapped) != 0 {
t.Errorf("a GET swapped the model: %v", core.swapped)
}
}
func TestModels_POSTRequiresStepUpWhenFailingClosed(t *testing.T) {
// No WebAuthn configured (nil session) + -require-stepup ⇒ deny, exactly
// like POST /tools. Nothing reaches core.
core := &fakeModelCore{}
w := modelsPOST(t, core, nil, true, "/opt/maven/models/llm/qwen3.gguf")
if w.Code != http.StatusForbidden {
t.Fatalf("POST /models without assertable step-up = %d; want 403", w.Code)
}
if len(core.swapped) != 0 {
t.Fatalf("a denied POST still called SwapModel: %v", core.swapped)
}
}
func TestModels_POSTSwapsAndReportsTheModelThatAnswered(t *testing.T) {
core := &fakeModelCore{
swapResp: ipc.SwapModelResp{Model: "qwen3-cpt", ModelPath: "/m/cpt.gguf", TookMs: 4200},
status: ipc.ModelStatusResp{Model: "qwen3-cpt", ModelPath: "/m/cpt.gguf"},
}
w := modelsPOST(t, core, nil, false, "/m/cpt.gguf")
if w.Code != http.StatusOK {
t.Fatalf("POST /models = %d; want 200", w.Code)
}
if len(core.swapped) != 1 || core.swapped[0].ModelPath != "/m/cpt.gguf" {
t.Fatalf("SwapModel calls = %v; want one for /m/cpt.gguf", core.swapped)
}
if !strings.Contains(w.Body.String(), "loaded qwen3-cpt") {
t.Errorf("page does not report which model was loaded:\n%s", w.Body.String())
}
}
func TestModels_RolledBackSwapSaysSheIsStillAnswering(t *testing.T) {
core := &fakeModelCore{
swapResp: ipc.SwapModelResp{Model: "qwen3", ModelPath: "/m/old.gguf", RolledBack: true},
swapErr: errBrokenModel{},
status: ipc.ModelStatusResp{Model: "qwen3", ModelPath: "/m/old.gguf"},
}
w := modelsPOST(t, core, nil, false, "/m/cpt.gguf")
if w.Code != http.StatusOK {
t.Fatalf("POST /models after a rollback = %d; want 200 with the failure rendered", w.Code)
}
body := w.Body.String()
if !strings.Contains(body, "rolled back to qwen3") {
t.Errorf("page does not say it rolled back:\n%s", body)
}
}
func TestModels_RefusedPathIs403(t *testing.T) {
core := &fakeModelCore{swapErr: ipc.ErrForbidden}
w := modelsPOST(t, core, nil, false, "/etc/passwd")
if w.Code != http.StatusForbidden {
t.Fatalf("POST /models with a non-allowlisted path = %d; want 403", w.Code)
}
}
func TestModels_UnconfiguredCoreRendersOff(t *testing.T) {
core := &fakeModelCore{statusErr: ipc.ErrUnknownMethod}
w := modelsGET(t, core)
if w.Code != http.StatusOK {
t.Fatalf("GET /models against a core without the swap = %d; want 200", w.Code)
}
if !strings.Contains(w.Body.String(), "swap not configured") {
t.Errorf("page does not say the capability is off:\n%s", w.Body.String())
}
}
func TestModels_CoreWithoutTheMethodsIs503(t *testing.T) {
// An in-process CoreAPI (no swap methods) must not 500 the page.
w := modelsGET(t, ipc.UnimplementedCoreAPI{})
if w.Code != http.StatusServiceUnavailable {
t.Fatalf("GET /models on a core without the methods = %d; want 503", w.Code)
}
}
type errBrokenModel struct{}
func (errBrokenModel) Error() string { return "llm: server did not start" }
+293
View File
@@ -0,0 +1,293 @@
package main
import (
"bytes"
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/binary"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"github.com/kami/maven/internal/webauthn"
)
const prfTestOrigin = "https://maven.test"
const prfTestRPID = "maven.test"
// fakeKeyIPC stands in for the mavend socket and records exactly what secret
// each call received — the point of the whole test file is that it is the PRF
// output and never the credential public key.
type fakeKeyIPC struct {
unlockSecret []byte
wrapSecret []byte
unlockCalls int
wrapCalls int
unlockErr error
}
func (f *fakeKeyIPC) Unlock(_ context.Context, secret []byte) error {
f.unlockCalls++
f.unlockSecret = bytes.Clone(secret)
return f.unlockErr
}
func (f *fakeKeyIPC) StoreEncryptionKey(_ context.Context, secret []byte) error {
f.wrapCalls++
f.wrapSecret = bytes.Clone(secret)
return nil
}
func b64u(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) }
// prfAuthenticator is a minimal software authenticator: a P-256 key plus the
// COSE encoding of its public half.
type prfAuthenticator struct {
key *ecdsa.PrivateKey
credID []byte
cose []byte
}
func newPRFAuthenticator(t *testing.T) *prfAuthenticator {
t.Helper()
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatalf("generate key: %v", err)
}
x := key.PublicKey.X.FillBytes(make([]byte, 32))
y := key.PublicKey.Y.FillBytes(make([]byte, 32))
// COSE_Key: {1: 2 (EC2), 3: -7 (ES256), -1: 1 (P-256), -2: x, -3: y}
var c []byte
c = append(c, 0xa5) // map(5)
c = append(c, 0x01, 0x02) // 1: 2
c = append(c, 0x03, 0x26) // 3: -7
c = append(c, 0x20, 0x01) // -1: 1
c = append(c, 0x21, 0x58, 0x20) // -2: bytes(32)
c = append(c, x...)
c = append(c, 0x22, 0x58, 0x20) // -3: bytes(32)
c = append(c, y...)
return &prfAuthenticator{key: key, credID: []byte("prf-cred"), cose: c}
}
func (a *prfAuthenticator) authData(flags byte, counter uint32, attested bool) []byte {
h := sha256.Sum256([]byte(prfTestRPID))
d := append([]byte{}, h[:]...)
d = append(d, flags)
cb := make([]byte, 4)
binary.BigEndian.PutUint32(cb, counter)
d = append(d, cb...)
if attested {
d = append(d, make([]byte, 16)...) // aaguid
l := make([]byte, 2)
binary.BigEndian.PutUint16(l, uint16(len(a.credID)))
d = append(d, l...)
d = append(d, a.credID...)
d = append(d, a.cose...)
}
return d
}
func clientDataJSON(typ, challenge string) []byte {
b, _ := json.Marshal(map[string]string{"type": typ, "challenge": challenge, "origin": prfTestOrigin})
return b
}
// register drives POST /register/finish with a valid attestation.
func (a *prfAuthenticator) register(t *testing.T, h *PasskeyHandle) {
t.Helper()
_, chal, err := h.rp.CreationOptions([]byte("u"), "user")
if err != nil {
t.Fatalf("CreationOptions: %v", err)
}
// {"fmt":"none","attStmt":{},"authData":<bytes>}
att := []byte{0xa3}
att = append(att, 0x63, 'f', 'm', 't', 0x64, 'n', 'o', 'n', 'e')
att = append(att, 0x67, 'a', 't', 't', 'S', 't', 'm', 't', 0xa0)
ad := a.authData(1<<6|0x05, 0, true)
att = append(att, 0x68, 'a', 'u', 't', 'h', 'D', 'a', 't', 'a')
att = append(att, 0x59, byte(len(ad)>>8), byte(len(ad)))
att = append(att, ad...)
body, _ := json.Marshal(map[string]any{
"challenge": chal,
"credential": map[string]any{
"id": b64u(a.credID),
"type": "public-key",
"response": map[string]any{
"clientDataJSON": b64u(clientDataJSON("webauthn.create", chal)),
"attestationObject": b64u(att),
},
},
})
w := httptest.NewRecorder()
h.RegisterFinish(w, httptest.NewRequest(http.MethodPost, "/auth/webauthn/register/finish", bytes.NewReader(body)))
if w.Code != http.StatusOK {
t.Fatalf("RegisterFinish: %d %s", w.Code, w.Body.String())
}
}
// assert drives POST /assert/finish with a valid assertion and the given
// base64url PRF result.
func (a *prfAuthenticator) assert(t *testing.T, h *PasskeyHandle, prf string) *httptest.ResponseRecorder {
t.Helper()
_, chal, err := h.rp.AssertionOptions()
if err != nil {
t.Fatalf("AssertionOptions: %v", err)
}
ad := a.authData(0x05, 7, false)
cdj := clientDataJSON("webauthn.get", chal)
hash := sha256.Sum256(cdj)
sig, err := ecdsa.SignASN1(rand.Reader, a.key, append(append([]byte{}, ad...), hash[:]...))
if err != nil {
t.Fatalf("sign: %v", err)
}
body, _ := json.Marshal(map[string]any{
"challenge": chal,
"prf": prf,
"credential": map[string]any{
"id": b64u(a.credID),
"type": "public-key",
"response": map[string]any{
"clientDataJSON": b64u(cdj),
"authenticatorData": b64u(ad),
"signature": b64u(sig),
},
},
})
w := httptest.NewRecorder()
h.AssertFinish(w, httptest.NewRequest(http.MethodPost, "/auth/webauthn/assert/finish", bytes.NewReader(body)))
return w
}
func newPRFHandle(t *testing.T, key *fakeKeyIPC) *PasskeyHandle {
t.Helper()
store, err := newCredentialStore(filepath.Join(t.TempDir(), "passkeys.json"))
if err != nil {
t.Fatalf("credential store: %v", err)
}
return &PasskeyHandle{
rp: webauthn.NewRP(webauthn.Config{Origin: prfTestOrigin, RPID: prfTestRPID, RPName: "maven"}),
encryptFn: key,
store: store,
session: webauthn.NewPasskeySession(0),
}
}
// The fix for Vikunja #14: what goes over IPC is the PRF secret from the
// authenticator, not the credential public key sitting in passkeys.json.
func TestAssertSendsPRFSecretNotPublicKey(t *testing.T) {
key := &fakeKeyIPC{}
h := newPRFHandle(t, key)
auth := newPRFAuthenticator(t)
auth.register(t, h)
// Enrolment must not wrap anything: create() yields no PRF result.
if key.wrapCalls != 0 || key.unlockCalls != 0 {
t.Fatalf("registration touched the key IPC (wrap=%d unlock=%d)", key.wrapCalls, key.unlockCalls)
}
secret := make([]byte, 32)
for i := range secret {
secret[i] = byte(i + 1)
}
if w := auth.assert(t, h, b64u(secret)); w.Code != http.StatusOK {
t.Fatalf("AssertFinish: %d %s", w.Code, w.Body.String())
}
if key.unlockCalls != 1 || key.wrapCalls != 1 {
t.Fatalf("unlock=%d wrap=%d, want 1 and 1", key.unlockCalls, key.wrapCalls)
}
if !bytes.Equal(key.unlockSecret, secret) {
t.Errorf("Unlock got %x, want the PRF secret %x", key.unlockSecret, secret)
}
if !bytes.Equal(key.wrapSecret, secret) {
t.Errorf("StoreEncryptionKey got %x, want the PRF secret %x", key.wrapSecret, secret)
}
// And explicitly: not the credential public key.
pub, _, err := h.store.Lookup(b64u(auth.credID))
if err != nil {
t.Fatalf("lookup: %v", err)
}
if bytes.Equal(key.unlockSecret, pub) {
t.Fatal("the credential public key was sent as the unlock secret")
}
}
// An authenticator without PRF must produce no unlock attempt at all — the
// assertion still succeeds (step-up works), but cold-start unlock stays off
// rather than falling back to something weaker.
func TestAssertWithoutPRFDoesNotUnlock(t *testing.T) {
for _, prf := range []string{"", "!!!not-base64!!!", b64u(make([]byte, 32)), b64u(make([]byte, 16))} {
key := &fakeKeyIPC{}
h := newPRFHandle(t, key)
auth := newPRFAuthenticator(t)
auth.register(t, h)
w := auth.assert(t, h, prf)
if w.Code != http.StatusOK {
t.Fatalf("prf=%q: AssertFinish %d %s", prf, w.Code, w.Body.String())
}
if key.unlockCalls != 0 || key.wrapCalls != 0 {
t.Errorf("prf=%q: unlock=%d wrap=%d, want no key IPC at all", prf, key.unlockCalls, key.wrapCalls)
}
}
}
// A failed unlock must not fail the assertion: step-up is independently valid,
// and a locked daemon degrades rather than breaking the login.
func TestAssertSucceedsWhenUnlockFails(t *testing.T) {
key := &fakeKeyIPC{unlockErr: errors.New("wrong credential")}
h := newPRFHandle(t, key)
auth := newPRFAuthenticator(t)
auth.register(t, h)
secret := bytes.Repeat([]byte{3}, 32)
if w := auth.assert(t, h, b64u(secret)); w.Code != http.StatusOK {
t.Fatalf("AssertFinish: %d %s", w.Code, w.Body.String())
}
if key.unlockCalls != 1 {
t.Errorf("unlock attempted %d times, want 1", key.unlockCalls)
}
}
// A forged assertion must never reach the unlock path.
func TestForgedAssertionNeverUnlocks(t *testing.T) {
key := &fakeKeyIPC{}
h := newPRFHandle(t, key)
auth := newPRFAuthenticator(t)
auth.register(t, h)
// A different key signing over the same credential id.
attacker := newPRFAuthenticator(t)
attacker.credID = auth.credID
w := attacker.assert(t, h, b64u(bytes.Repeat([]byte{4}, 32)))
if w.Code == http.StatusOK {
t.Fatal("an assertion signed by the wrong key was accepted")
}
if key.unlockCalls != 0 || key.wrapCalls != 0 {
t.Fatalf("a forged assertion reached the key IPC (unlock=%d wrap=%d)", key.unlockCalls, key.wrapCalls)
}
}
// The browser side is the only place the PRF result exists. If the page stops
// asking for it or stops reading it back, cold-start unlock silently dies with
// nothing failing, so the page source is asserted directly.
func TestPasskeyPageRequestsAndPostsPRF(t *testing.T) {
for _, want := range []string{
"getClientExtensionResults",
"ext.prf.results.first",
"body:JSON.stringify({challenge,prf,",
} {
if !strings.Contains(passkeyPageHTML, want) {
t.Errorf("the passkey page no longer contains %q", want)
}
}
}
+52 -33
View File
@@ -23,8 +23,8 @@ type assertIPC interface {
// is *ipc.Client; in-process CoreAPI adapters do not implement it. When nil,
// StoreEncryptionKey and Unlock are silently skipped.
type keyIPC interface {
StoreEncryptionKey(ctx context.Context, publicKey []byte) error
Unlock(ctx context.Context, publicKey []byte) error
StoreEncryptionKey(ctx context.Context, secret []byte) error
Unlock(ctx context.Context, secret []byte) error
}
// PasskeyHandle holds the WebAuthn relying party, a local in-memory credential
@@ -102,17 +102,31 @@ async function enroll(){try{
const r=await fetch('/auth/webauthn/register/finish',{method:'POST',headers:{'content-type':'application/json'},
body:JSON.stringify({challenge,credential:{id:c.id,type:c.type,response:{
clientDataJSON:b64u(c.response.clientDataJSON),attestationObject:b64u(c.response.attestationObject)}}})});
say(r.ok?'enrolled ✓':'enroll failed: '+await r.text(),r.ok);
if(!r.ok){say('enroll failed: '+await r.text(),false);return;}
// The wrapped key can only be written from an assertion: PRF results are
// not produced at create() time on most authenticators. Enrolment reports
// whether PRF is available at all so he is not told cold-start works when
// it cannot.
const ext=c.getClientExtensionResults?c.getClientExtensionResults():{};
const prfOK=!!(ext.prf&&ext.prf.enabled);
say(prfOK?'enrolled ✓ — now assert once to write the cold-start key':
'enrolled ✓ — but this authenticator has no PRF: cold-start unlock unavailable',true);
}catch(e){say('enroll error: '+e,false);}}
async function assert(){try{
const {challenge,options}=await (await fetch('/auth/webauthn/assert/begin')).json();
options.challenge=ub64(options.challenge);
const c=await navigator.credentials.get({publicKey:options});
// The PRF result is the cold-start secret. It never touches localStorage
// and is posted once, over the same request as the assertion.
const ext=c.getClientExtensionResults?c.getClientExtensionResults():{};
const prf=ext.prf&&ext.prf.results&&ext.prf.results.first?b64u(ext.prf.results.first):'';
const r=await fetch('/auth/webauthn/assert/finish',{method:'POST',headers:{'content-type':'application/json'},
body:JSON.stringify({challenge,credential:{id:c.id,type:c.type,response:{
body:JSON.stringify({challenge,prf,credential:{id:c.id,type:c.type,response:{
clientDataJSON:b64u(c.response.clientDataJSON),authenticatorData:b64u(c.response.authenticatorData),
signature:b64u(c.response.signature)}}})});
say(r.ok?'stepped up ✓ — enable tools now':'assert failed: '+await r.text(),r.ok);
if(!r.ok){say('assert failed: '+await r.text(),false);return;}
say(prf?'stepped up ✓ — enable tools now':
'stepped up ✓ — no PRF from this authenticator, so cold-start unlock stayed unavailable',true);
}catch(e){say('assert error: '+e,false);}}
</script>`
@@ -140,9 +154,7 @@ func (h *PasskeyHandle) RegisterFinish(w http.ResponseWriter, r *http.Request) {
http.Error(w, "bad request: "+err.Error(), http.StatusBadRequest)
return
}
var enrolledPublicKey []byte
save := func(id string, publicKey []byte, _ []byte, _ string) error {
enrolledPublicKey = publicKey
return h.store.Save(id, publicKey)
}
credID, err := h.rp.FinishRegistration(save, body.Challenge, body.Credential)
@@ -153,19 +165,15 @@ func (h *PasskeyHandle) RegisterFinish(w http.ResponseWriter, r *http.Request) {
}
log.Printf("webauthn: registered credential %s", credID)
// If mavend is reachable and supports key wrapping, store the encryption
// key wrapped with this credential's public key — enables cold-start unlock.
if h.encryptFn != nil && enrolledPublicKey != nil {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
if err := h.encryptFn.StoreEncryptionKey(ctx, enrolledPublicKey); err != nil {
log.Printf("webauthn: store encryption key: %v", err)
// Non-fatal: enrollment still succeeded, the wrapped key can be
// created later via the same endpoint.
} else {
log.Printf("webauthn: encryption key wrapped with credential %s", credID)
}
}
// Note what does NOT happen here: the encryption key is not wrapped at
// enrolment. Wrapping needs the authenticator's PRF output, and create()
// does not produce one on most authenticators — it only reports whether
// the extension is supported. The wrapped key is written on the first
// assertion instead (see AssertFinish).
//
// This used to wrap the key under the credential *public* key, which is
// written to passkeys.json next to the wrapped blob. See the header of
// internal/webauthn/keywrap.go.
json.NewEncoder(w).Encode(map[string]string{"credential_id": credID})
}
@@ -189,6 +197,11 @@ func (h *PasskeyHandle) AssertFinish(w http.ResponseWriter, r *http.Request) {
var body struct {
Challenge string `json:"challenge"`
Credential map[string]any `json:"credential"`
// PRF is the base64url WebAuthn PRF output the browser read out of
// getClientExtensionResults(). Empty when the authenticator has no
// PRF extension: cold-start unlock is then unavailable and we say so
// rather than falling back to something weaker.
PRF string `json:"prf"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
http.Error(w, "bad request: "+err.Error(), http.StatusBadRequest)
@@ -222,26 +235,32 @@ func (h *PasskeyHandle) AssertFinish(w http.ResponseWriter, r *http.Request) {
}
}
// If the daemon is locked (cold-start), send the credential's public key
// over IPC so mavend can unwrap its encryption key and open the store.
// The public key comes from the local credential store (it was stored
// during enrollment). Non-fatal: if IPC doesn't support Unlock or the
// daemon is already unlocked, the call is a no-op on the server side.
// Cold-start unlock and key wrapping, both keyed on the PRF secret that
// this assertion just produced. The secret is used here and dropped; it is
// never stored on this side.
//
// Order matters: unlock first (if the daemon is locked there is nothing to
// wrap yet), then re-wrap, which writes the blob on the first assertion
// after enrolment and is a harmless rewrite afterwards. Both are
// best-effort — the assertion itself is valid either way.
if h.encryptFn != nil {
publicKey, _, err := h.store.Lookup(credID)
if err == nil && publicKey != nil {
secret, err := webauthn.DecodePRFResult(body.PRF)
switch {
case err != nil:
log.Printf("webauthn: no usable PRF secret from credential %s: %v", credID, err)
default:
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
if err := h.encryptFn.Unlock(ctx, publicKey); err != nil {
if err := h.encryptFn.Unlock(ctx, secret); err != nil {
log.Printf("webauthn: unlock via credential %s: %v", credID, err)
// Non-fatal: assertion succeeded; if the daemon stays locked
// the user will see errors on subsequent pages, but the
// assertion itself is valid.
} else {
log.Printf("webauthn: daemon unlocked via credential %s", credID)
}
} else if err != nil {
log.Printf("webauthn: lookup credential %s for unlock: %v", credID, err)
if err := h.encryptFn.StoreEncryptionKey(ctx, secret); err != nil {
log.Printf("webauthn: wrap encryption key: %v", err)
} else {
log.Printf("webauthn: encryption key wrapped for credential %s", credID)
}
}
}
+107
View File
@@ -36,6 +36,67 @@ present (see `.dockerignore`).
| `/var/lib/maven` (volume) | encrypted db at rest |
| `/dev/shm` (tmpfs) | decrypted db working copy (RAM only) |
## Reading the outside world (off by default)
`mavend.json` ships without a `feeds` block, which means no RSS/Atom feed is
fetched and no outbound request is made. Switching it on is adding the block:
```json
"feeds": {
"poll_interval": "30m",
"max_items": 5,
"max_age": "24h",
"sources": [
{ "name": "habr", "url": "https://habr.com/ru/rss/best/daily/",
"category": "технологии", "exclude": ["реклама"] }
]
}
```
What it does and does not do:
- items are written as notes with source `rss:<name>`, visible on `/dash`;
- **nothing is announced.** She reads them back when asked — "что нового в
лентах?", "что нового по технологиям?" — and never on arrival. There is no
severity or channel knob here on purpose;
- the fetcher is allowlisted to the hosts of the configured feeds, plus any
`allow_hosts`. It refuses non-http(s) schemes and every private address
(loopback, the LAN, the `10.42.0.0/24` wg range, cloud metadata). It caps the
response at 2 MiB and redirects at 3, and makes at most one request per host
per second. See `internal/webfetch`;
- how far each feed was read is stored as a config fact `rss:latest:<name>`, so
a restart does not re-note yesterday's headlines.
### Reading a page (`crawl`, also off by default)
There is no `crawl` block either, so no page is fetched. Two halves, separately
switched:
```json
"crawl": {
"on_demand": true,
"interval": "6h",
"max_runes": 4000,
"watches": [
{ "name": "changelog", "url": "https://example.org/changelog", "interval": "12h" }
]
}
```
- `on_demand` lets her read a page he names in the utterance: "посмотри
https://example.org/x — что там?". The page becomes context for his question,
and only the URL leaves the box. Without a URL nothing is fetched, so this is
a fallback and not a habit;
- `watches` re-reads a fixed list on its interval and writes a note when the
text changed. Like the feeds, it announces nothing;
- the answer path sits **last** in the query chain, behind his memory, his notes
and (once wired) the local Kiwix ZIMs. A local read costs nothing;
- `robots.txt` is fetched first and obeyed with no override; a `Disallow` is a
refusal she says out loud. `Crawl-delay` is honoured;
- same guarded fetcher as the feeds: allowlist/denylist, no private addresses,
size cap, redirect cap, timeout, one request per host per second;
- dedup state is the config fact `crawl:hash:<name>`.
## Not yet verified / host-dependent
This stack is correct-by-construction but has **not been build-tested here**
@@ -53,3 +114,49 @@ build on the target host, most likely in one of these:
work fine over the core socket.
- **netdata** — `mavpoll` reaches it via `host.docker.internal`; adjust if
netdata runs elsewhere.
## Updating her (`mavupdate`, Vikunja #249)
Off unless configured, and there is deliberately no button for it. There is no
IPC method, no web route, no timer and no act that starts an update — the trigger
is a human running `mavupdate` on the host, which needs shell access, a strictly
higher bar than the step-up passkey gate that guards `/tools`. She cannot update
herself; she can be updated. Nothing here ever fetches code: the new version is
whatever you pulled into the working tree yourself.
Add an `update` block to `mavend.json` (mavend ignores it — only the CLI reads
it), with paths as they exist **on the host**, not inside a container:
```json
"update": {
"source_dir": "/home/kami/apps/Maven",
"install_dir": "/home/kami/apps/Maven",
"snapshot_dir": "/var/lib/maven-snapshots",
"binaries": ["mavend", "mavweb", "mavsttd", "mavttsd", "mavwaked",
"mavenclient", "mavpoll", "mavcaldav", "mavmaild"],
"config_files": ["deploy/mavend.json"],
"restart_cmd": ["docker", "compose", "up", "-d", "--build"],
"health_socket": "/var/lib/docker/volumes/maven_sockets/_data/mavend.sock",
"health_timeout_sec": 120
}
```
`snapshot_dir` must be outside `install_dir` (a restore must not read from what
the install writes) and `health_socket` is required: an update that cannot check
its own result cannot roll itself back, so the config is refused without one.
Then:
```sh
mavupdate -config deploy/mavend.json verify # make build + make test, deploys nothing
mavupdate -config deploy/mavend.json apply -yes # snapshot, verify, install, restart, health-check
mavupdate -config deploy/mavend.json list # what you can roll back to
mavupdate -config deploy/mavend.json rollback -yes # restore the previous artifacts and restart
```
`apply` refuses to start if she is not already answering — otherwise a failed
update and a box that was already broken are indistinguishable afterwards. On any
failure after the install it restores the snapshot, restarts, and checks again;
if that also fails it says so loudly and names the directory to copy back by hand.
The database is never snapshotted or rolled back (see the package comment in
`internal/update`); schema compatibility is `store.Migrate`'s job.
+14
View File
@@ -31,6 +31,20 @@
"cooldown": "24h"
},
"mcp": {
"timeout": "15s",
"servers": [
{
"name": "vikunja",
"url": "http://192.168.1.104:9100/mcp",
"allow_private": true,
"allow_tools": ["list_projects", "list_tasks", "get_task_details", "create_task"],
"max_tools": 6,
"enabled": false
}
]
},
"nexus": { "url": "http://nexus:9740" },
"praxis": { "url": "http://praxis:8989" },
"hexis": { "url": "http://hexis:9741" },
+33
View File
@@ -101,9 +101,42 @@ services:
"-netdata", "http://127.0.0.1:19999",
"-kuma", "http://127.0.0.1:3001/metrics",
"-kuma-key", "uk5_mavpoll-key"]
# Money tracking (Vikunja #125) is OFF: it needs a zenmoney token,
# which mavpoll reads from a FILE so it never appears in `ps`, in
# this file, or in shell history. To enable, mount the token and
# append: "-zenmoney-token-file", "/run/secrets/zenmoney.token"
# (optionally "-zenmoney-interval", "1h"). Core never sees the
# token — the poller writes facts(kind=env, source=poll:zenmoney)
# and mavend only reads those back when he asks.
depends_on: [mavend]
volumes:
- sockets:/run/maven
# - ./deploy/zenmoney.token:/run/secrets/zenmoney.token:ro
# The mail reader (Vikunja #246) is OFF and commented out: it needs an IMAP
# account, and there is none on this box. mavmaild reads the password from a
# FILE so it never appears in `ps`, in this file, or in shell history — the
# same rule mavpoll follows for the zenmoney token. Core never sees the
# password: the reader hands core message text on one IPC method, and core
# writes what the model extracts as task CANDIDATES he reviews on /tasks.
# Nothing here can create a reminder, so a misread mail cannot fire.
#
# To enable: write the password to deploy/imap.password (0600, gitignored),
# add an "email": {} block to deploy/mavend.json, and uncomment this service.
# mavmaild:
# <<: *image
# command: ["mavmaild", "-socket", "/run/maven/mavend.sock",
# "-imap", "imap.example.org:993",
# "-user", "kami@example.org",
# "-password-file", "/run/secrets/imap.password",
# "-mailbox", "INBOX",
# "-interval", "15m",
# "-state", "/var/lib/maven/mail-seen.json"]
# depends_on: [mavend]
# volumes:
# - sockets:/run/maven
# - dbdata:/var/lib/maven
# - ./deploy/imap.password:/run/secrets/imap.password:ro
volumes:
dbdata:
+101 -22
View File
@@ -1,27 +1,106 @@
# Plan: Vision — Image Understanding Capability
**Goal:** Maven can "see" — accept images (from mavweb upload, Telegram, or filesystem paths), run vision inference via a local or remote multimodal model, and answer questions about the image content or extract structured information.
**Goal:** Maven can "see" — accept images (from mavweb upload, Telegram, or filesystem paths), store them, run inference via a **local** multimodal model, and answer questions about the image content or extract text from it.
**Done when:**
- Vision model backend is configurable: local multimodal LLM (e.g., LLaVA, Qwen-VL via `llama-server` mmproj) or remote API
- `internal/vision/` package handles image preprocessing, model inference, result parsing
- Voice/text commands like "что на картинке?" or "прочитай текст с экрана" route to the vision handler
- Extracted information can be written as facts/notes through `ipc.CoreAPI`
- Telegram image messages are processed through the same pipeline
**Status (2026-08-01):** intake, storage, config seam and the provider are shipped. The
describing half is **BLOCKED on a model download** — see "What is blocked" below.
**Scope:**
- New `internal/vision/` package — image loader (Go stdlib `image` + `golang.org/x/image`), inference client
- New config block: `voice.vision` in `config.Config``{enabled, provider, model_path, mmproj_path, remote_url}`
- Router intent extension: new `IntentVision` or reuse `IntentQuery` with a vision flag
- Reuses `internal/llm.Client` for API-compatible backends (OpenAI-compatible vision API)
- Reuses `internal/ipc.CoreAPI` for writing extracted data
## What shipped
**Steps:**
1. Create `internal/vision/provider.go``Provider` interface with `Describe(image []byte, prompt string) (string, error)` and `ExtractText(image []byte) (string, error)`
2. Implement `LocalProvider` — spawns `llama-server` with mmproj, sends multimodal chat completion requests
3. Implement `RemoteProvider` — calls an OpenAI-compatible vision API endpoint, reuses `internal/llm.Client`
4. Create `internal/vision/processor.go` — image preprocessing (resize, format conversion to JPEG/PNG, base64 encoding)
5. Wire vision into `cmd/mavend/voice.go:reactiveHandler` — detect vision intent from router (new `IntentVision` or a `Slots.HasImage` flag)
6. Add IPC method `MethodDescribeImage` for programmatic access (mavweb upload, telegram bot)
7. Add vision config block to `config.Config` and wire in `cmd/mavend/main.go`
8. Test with a local multimodal model: send an image via mavweb, verify description and text extraction
| Piece | Where |
|---|---|
| Blob store (content-addressed, retention-pruned) | `internal/media/store.go` |
| Image decode / flatten / downscale / JPEG | `internal/media/image.go` |
| `Provider` seam + `Disabled` floor + `LocalProvider` | `internal/vision/vision.go` |
| Store-then-describe orchestration, re-runnable | `internal/vision/intake.go` |
| Config blocks `media` and `vision` | `internal/config/config.go` |
| IPC method `describe_image` (`AuthRead`) | `internal/ipc/{wire,api,client,server}.go`, `internal/auth/policy.go` |
| Daemon wiring + hourly retention prune | `cmd/mavend/vision.go` |
`internal/media` is deliberately shared: hearing (#253) and speaker recognition (#255) have
the same intake problem — a blob arrives, gets stored, gets described — and they store their
audio in the same place under the same retention.
## Design decisions worth knowing
**Store before describe.** `Intake.Accept` writes the blob to disk *first*, then asks the
model. If the model is missing or broken — which is this box's actual state — the answer is
"it's kept, I can't read it yet" with a content-addressed id, and `Intake.Rerun(id, question)`
describes it later. Nothing is lost to a missing model.
**No `RemoteProvider`.** The original step 3 called for "an OpenAI-compatible vision API
endpoint". Refused. The surviving hard constraint in CLAUDE.md after "never phones home" was
deprecated is *no cloud model, inference stays on the box*, and a photo of his flat is the
worst possible exception. `vision.NewLocal` therefore validates the endpoint at construction:
loopback, a private IP, or `localhost`. A hostname is refused too — it could resolve anywhere,
and resolving it would mean trusting DNS with his pictures.
**Blobs are not in the database.** The sqlite store is small, encrypted and read every tick;
a 40 MB blob has no business there. What lands in the database is the *text* the blob produced,
as an ordinary note (`source: media:image:<id-prefix>`), and only when the caller asks for it
(`save_note`). Glancing at a screenshot is not the same act as remembering it.
**Images are never search input and never embedded.** Only the derived description
participates in recall, and only after he can see it as a note.
**Retention is enforced by a loop, not by a promise.** `media.retention` defaults to 7 days
and `cmd/mavend` prunes hourly, starting at boot. A store that grows forever would be the real
failure mode of this capability.
**No webp.** The stdlib has no webp decoder and this repo takes no new dependencies (the box
is offline). `media.SniffImage` recognises webp well enough to refuse it *by name*, so the log
says "webp is not supported" instead of "not an image". Telegram sends webp for stickers; that
is a known gap, not a mystery.
**Text extraction is not a second method.** "прочитай текст с картинки" is a prompt. A VLM has
no separate OCR mode to select, and a second interface method would only duplicate the first.
## What is blocked, and on what
There is **no vision-capable gguf and no mmproj file on this box**. Checked 2026-08-01:
```
/mnt/hdd1/llms/{Bonsai,LFM2.5,llama3.2,ministral,nemotron3-nano,qwen3,qwen3.5}
```
— sixteen ggufs, all text-only, no `*mmproj*` anywhere. The resident Qwen3-1.7B is text-only
by construction, so vision needs a *second* model. The ≤1.7B ceiling in CLAUDE.md is about the
resident router/phraser, not about a second model loaded on demand — but iGPU VRAM still is,
so keep it small.
To unblock, download one pair to `/mnt/hdd1/llms/vision/` (bind-mounted to
`/opt/maven/models/llm`), a gguf **and** its mmproj:
- `Qwen2.5-VL-3B-Instruct` (Q4_K_M + `mmproj-F16.gguf`) — the safe default; reads Russian, and
its OCR is the best of this size class.
- `SmolVLM2-2.2B-Instruct` — smaller and faster, weaker at Cyrillic text in images.
- `moondream2` — smallest, English-only in practice. Do not bother, per the sub-500M lesson.
Then run a second llama-server on 8081 with `--mmproj`, point `vision.endpoint` at it, and
walk the QA steps on Vikunja #252.
## Config
```json
"media": { "dir": "media", "retention": "168h", "max_bytes": 67108864 },
"vision": {
"enabled": true,
"endpoint": "http://127.0.0.1:8081",
"model": "qwen2.5-vl-3b",
"max_dim": 896,
"max_tokens": 300,
"timeout": "90s"
}
```
Both absent by default. No `media` block ⇒ `describe_image` does not exist at all; a `media`
block with no `vision` block ⇒ images are stored and honestly not described.
## Still open
- **Router intent.** "что на картинке?" does not route anywhere yet. Adding an intent is
premature while nothing can answer it; the IPC method is the surface a Telegram photo or a
mavweb upload calls today.
- **Telegram photo path** in `mavpoll` (download the file, call `DescribeImage`).
- **mavweb upload page** and a `/media` listing so stored blobs are visible and deletable from
the authed surface.
+117 -24
View File
@@ -1,28 +1,121 @@
# Plan: Hearing — Audio Stream Monitoring & Meeting Summarization
# Plan: Hearing — Meeting Capture & Summarisation
**Goal:** Maven can "hear" ambient audio from workpc — microphone input during meetings, system audio — and on demand (or on trigger) produce transcripts, summaries, or extract action items. A typical use case: "Maven, запиши встречу" starts capture, "хватит" stops it, and Maven writes a summary note.
**Goal:** "Maven, запиши встречу" starts a recording, "хватит" stops it, and she writes a
summary note. The audio stays on the box, is pruned by retention, and nothing is recorded that
nobody asked for.
**Done when:**
- `internal/audio/capture.go` — remote microphone capture client (receives PCM stream from workpc over WebSocket or the existing voice TCP protocol)
- `internal/stt/` — streaming transcription (uses existing `stt.Transcriber` interface, extended with streaming support)
- Meeting capture triggered by voice command (IntentCapture) or configurable keyword ("maven record")
- Raw audio is either streamed to STT in real-time or saved to a WAV file and transcribed after capture ends
- Transcription + LLM summary is written as a note (`source:capture:meeting`) through `ipc.CoreAPI`
- New `mavheary` module (`cmd/mavheard/`) — the workpc-side agent that captures mic/speaker audio and streams it to mavend
**Status (2026-08-01):** the recorder, the storage, the chunked transcription, the map-reduce
summariser, the config seam and the four IPC methods are shipped and tested. What is not
shipped is the workpc-side microphone agent and the router intent — see "Still open".
**Scope:**
- New `cmd/mavheard/` — workpc-side agent: captures microphone (PortAudio or ALSA `arecord`), streams over WebSocket to mavend
- `internal/audio/` extended with capture types: `MicCapture`, `SystemCapture`, `FileCapture`
- `internal/stt/stt.go` extended with `StreamingTranscriber` interface (or reuse existing with chunked input)
- Router: new `IntentCapture` intent for start/stop commands
- Reuses `internal/llm.Client` for summarization
- Reuses `internal/voice/server.go` TCP protocol for streaming audio
## What shipped
**Steps:**
1. Create `cmd/mavheard/main.go` — workpc-side daemon: captures microphone via `arecord` pipe or PortAudio, opens WebSocket or TCP connection to mavend, streams PCM frames
2. Create `internal/audio/capture.go``Capture` interface: `Start()`, `Stop()`, `AudioCh <-chan Audio`; implement `MicCapture` (reads from `mavheard` stream) and `FileCapture` (reads WAV)
3. Extend `internal/stt/stt.go` — add `TranscribeStream(ctx, audio <-chan Audio) (string, error)` to `Transcriber` interface; `Stub` returns empty; `Remote` forwards chunks to worker socket
4. Add `IntentCapture` to `internal/router/intent.go` — slots: `Action` ("start"/"stop"/"status"), `Duration`
5. Wire capture handler in `cmd/mavend/voice.go:reactiveHandler` — start = spawn goroutine receiving audio, stream to STT; stop = finalize, send to LLM for summarization, write note via `WriteNote`
6. Add capture config to `voice` block in `config.Config` `{capture_enabled, capture_timeout}`
7. Test with a recorded WAV file — simulate a meeting, verify transcription + summary note is created
| Piece | Where |
|---|---|
| Session state machine: start / append / stop / abort / status | `internal/capture/capture.go` |
| Map-reduce summarisation against `n_ctx` 4096 | `internal/capture/summarize.go` |
| Audio blobs in the shared store, pruned by `media.retention` | `internal/media` (from #252) |
| Config block `capture`, off by default | `internal/config/config.go` |
| IPC `capture_start` / `capture_append` / `capture_stop` / `capture_status` | `internal/ipc/{wire,api,client,server}.go` |
| Authority: the three write methods `AuthWrite`, status `AuthRead` | `internal/auth/policy.go` |
| Daemon wiring, note write, STT reuse | `cmd/mavend/capture.go` |
The audio lands in the same content-addressed blob store as images, under the same retention
loop, because #252 and #253 have the same intake problem and solving it twice would mean two
directories to remember to prune.
## The refusals, and why
**Nothing listens.** The original step 8 called for capture "triggered by voice command
(IntentCapture) **or configurable keyword ('maven record')**". The keyword half is refused.
Noticing a keyword requires listening to the room continuously, which is precisely the
behaviour this capability must not have, and the refusal is in the code rather than in a
comment: `Recorder.Append` is the only way audio enters, and it returns `ErrNoSession` unless
someone explicitly started a session. Audio arriving at an idle core is dropped, not buffered
"just in case".
**Off unless configured, twice over.** No `media` block ⇒ nowhere to keep audio ⇒ the four
methods do not exist. No `capture` block with `enabled: true` ⇒ they still do not exist. On an
unconfigured box there is no wire path at all that begins a recording. That is the only
guarantee worth making here, and it is the reason the hooks use the nil-hook ⇒
`ErrUnknownMethod` pattern rather than an in-handler check.
**A forgotten session ends itself.** `max_minutes` defaults to 120 and is checked on every
append, not on a timer that could be missed. Past the cap `Append` returns `ErrExpired`
permanently, so a client that ignores the error cannot grow the recording; the audio collected
before the cap is kept and `Stop` still works.
**"Забудь, не записывай" leaves nothing behind.** `capture_stop` with `discard: true` throws
the session away without storing, transcribing or summarising anything — not a blob with a note
saying it was abandoned. Nothing.
**The transcript is not saved by default.** The summary is written where he will read it; the
verbatim record of what other people said in a room is a heavier thing to keep and takes a
deliberate `save_transcript: true`. The audio blob is pruned by `media.retention` either way.
**No second STT.** Step 3 of the original plan extended the `Transcriber` interface with
streaming. Not needed and not done: whisper.cpp already runs as `mavsttd`, and `internal/capture`
takes the ordinary `stt.Transcriber` the voice path already holds (exposed as
`voiceWiring.transcriber`). Long recordings are handed over in five-minute windows —
`chunkAudio`, cut on sample boundaries — for the same reason whisper itself works in 30-second
windows: an hour of PCM in one call either times out or blocks the voice path for minutes.
Capture with voice off is refused rather than degraded, because storing hours of unreadable
audio of other people is worse than not recording.
**Not `AuthStepUp`.** Recording people is invasive enough to argue for the top rung, and it is
still wrong: step-up needs a passkey gesture, which the voice path cannot make, so
"запиши встречу" could never work by voice — the only way he will actually use this. `AuthWrite`
plus the off-unless-configured gate is the honest combination.
## Long audio against a 4096-token context
The resident model is a Thinking variant at `n_ctx` 4096, so an hour of transcript does not fit
in one prompt and never will. `summarize.go` does map-reduce and nothing cleverer: split the
transcript on sentence boundaries into 3000-rune windows (about 1100 Qwen tokens of Russian,
leaving room for the persona block, the reasoning and the answer), summarise each, then
summarise the summaries. A transcript that fits in one window skips the reduce step.
Truncation was the alternative and is rejected: a truncated meeting summary reads as complete
and is not, and he would act on it. Past `max_chunks` (40, roughly the two-hour cap) the
transcript *is* cut, and the summary says so in the note.
Two degradations are deliberate and both are reported rather than hidden:
- No llama-server ⇒ transcript, no summary. The words exist.
- The reduce call fails ⇒ the per-chunk summaries are returned joined. Real work, not thrown
away over the last call.
The map and reduce prompts contain no first person at all, so the persona's feminine-form rules
have nothing to get wrong in them; the reply she actually gives him is phrased by the ordinary
replier, which does carry the persona.
## Config
```json
"media": { "dir": "media", "retention": "168h" },
"capture": {
"enabled": true,
"max_minutes": 120,
"stt_window": "5m",
"chunk_runes": 3000,
"max_chunks": 40,
"save_transcript": false
}
```
Both absent by default. `capture` alone does nothing without `media`.
## Still open
- **`cmd/mavheard`** — the workpc-side microphone agent. Deferred, not refused: the core half
is the part with the invariants in it, and a mic client is straightforward once there is a
stable wire to stream at. It should be an explicit-start process, not a resident one, for the
same reason the recorder has no keyword trigger. The four IPC methods are the wire it will
use; `mavenclient` already has the mic plumbing to borrow.
- **Router intent.** "запиши встречу" / "хватит" does not route anywhere yet. It needs the
`system` intent plus slots, and it needs care: "хватит" is also how someone tells her to stop
talking, so the recorder's stop and the speech barge-in must not collide.
- **A `/dash` panel** showing a running session, so a recording is visible on a surface and not
only in a log line.
- **Speaker attribution** — who said what — is #255 and is blocked on a model; see
`docs/plans/10-speaker-recognition.md`.
+120 -22
View File
@@ -1,27 +1,125 @@
# Plan: Speaker Recognition
**Goal:** Maven can distinguish between different speakers on the voice channel — recognize known voices (the user, family members) and tag facts/notes/transcripts with a speaker identity.
**Goal:** Maven can tell who is speaking on the voice channel, and tag what she writes with
who said it.
**Done when:**
- Speaker embedding extractor (e.g., ECAPA-TDNN or a simple MFCC + GMM) runs on incoming voice PCM before STT
- Embedding is compared against enrolled speaker profiles (stored as vectors in the `memory_vectors` table alongside semantic memory)
- Unknown speakers are enrolled on first interaction (prompt: "кто это?")
- All voice fact/note writes are tagged with `speaker:<id>` in the value/source metadata
- Speaker identity is available as context to the router, phraser, and replier ("ok, <name>")
**Status (2026-08-01, Vikunja #255):** the enrolment half is shipped. The recognising half is
**BLOCKED on a model download** — there is no speaker-embedding model on this box, and one
was not invented to fill the gap. See "Blocked, and on what" below.
**Scope:**
- New `internal/speaker/` package — enrollment, recognition, embedding extraction
- Reuses `internal/store.MemoryStore` for speaker vector storage (same `memory_vectors` table, different `source` prefix)
- Reuses `internal/audio` for PCM preprocessing
- Integration point: `cmd/mavend/voice.go:HandlePushToTalk` — speaker ID extracted before STT, passed through context
## What shipped
**Steps:**
1. Research speaker embedding approaches — simplest floor: MFCC + cosine similarity via `github.com/mjibson/go-dsp` or a pre-trained ONNX model (SpeechBrain ECAPA)
2. Create `internal/speaker/recognizer.go``Recognizer` interface: `Identify(pcm []float32) (SpeakerID, confidence)`, `Enroll(id, pcm)`
3. Create `internal/speaker/store.go` — speaker profile CRUD via `store.MemoryStore`: `Insert("speaker:<id>", embedding, meta)`, `Search(embedding, k)`
4. Create `internal/speaker/enroll.go` — enrollment flow: capture N seconds of audio, extract embedding, prompt for name via TTS + STT round-trip
5. Wire into `cmd/mavend/voice.go:HandlePushToTalk` — run speaker ID on the PCM before STT; pass speaker ID through `context.Context` to `applyAction`
6. Tag all voice-written facts/notes with speaker ID — `Source` becomes `tap:voice:speaker:<id>` or metadata field
7. Add IPC methods `MethodEnrollSpeaker`, `MethodListSpeakers`, `MethodRemoveSpeaker`
8. Add speaker config block to `voice` in `config.Config``{speaker_recognition: true, model_path}`
9. Test with 2+ recorded voice samples — verify correct identification and rejection of unknown speakers
| Piece | Where | State |
|---|---|---|
| `Recognizer` — identify, list, get, forget | `internal/speaker/recognizer.go` | done; `Identify` answers `ErrDisabled` until a model exists |
| Enrolment — several samples, averaged, re-normalised | `internal/speaker/enroll.go` | done |
| Profile shape, id validation, cosine similarity | `internal/speaker/speaker.go` | done |
| Profile storage as `speaker:<id>` vectors | `internal/memory` `Catalog` + `internal/store/memory.go` | done, no schema migration |
| Config block, off by default | `internal/config` `SpeakerConfig` | done |
| `enroll_speaker` / `list_speakers` / `forget_speaker` | `internal/ipc` | done, absent unless configured |
| Authority rows | `internal/auth/policy.go` | done — enrol step-up, forget write, list read |
| Daemon wiring + honest startup log | `cmd/mavend/speaker.go` | done |
| Embedding backend | `newSpeakerEmbedder` | **BLOCKED** — returns nil, seam only |
| Tagging voice writes with the speaker | `cmd/mavend/voice.go` | not wired; nothing to tag with yet |
## Blocked, and on what
A voiceprint needs a speaker-embedding model. The box was searched: `/mnt/hdd1/llms` holds
sixteen ggufs across seven families and every one of them is a text model. There is no ECAPA,
no x-vector, no titanet, no wespeaker, and no `.onnx` under `/mnt/hdd1` at all. There are also
no enrolment samples, because nothing has ever recorded any.
To unblock, two things are needed and neither can be done from inside the repo:
1. **A model.** SpeechBrain ECAPA-TDNN exported to ONNX (`speechbrain/spkrec-ecapa-voxceleb`,
192-dim) is the usual choice and runs on CPU in well under a second for a few seconds of
audio. Download it per the recipe in `AGENTS.md`, put it beside the other models so the
bind mount picks it up, and point `speaker.model_path` at it.
2. **An implementation of one function.** `newSpeakerEmbedder` in `cmd/mavend/speaker.go` is
the entire seam: give it an ONNX session that turns `audio.Audio` into a `[]float32` and
`Identify` starts working. Nothing else changes — not the store, not the protocol, not the
authority table, not the handlers. `internal/onnx` already loads the e5 embedder, so the
runtime wiring exists to copy.
3. **Enrolment samples**, three or more per person, recorded deliberately.
### Why there is no fallback
The original plan offered "a simple MFCC + GMM" as the floor. That is refused. MFCC cosine
distance is a channel and loudness detector as much as a voice detector: it will happily match
two different people who sit at the same distance from the same microphone, and it drifts when
the room changes. A general classifier that is sometimes wrong is a nuisance; a **biometric**
that is confidently wrong writes false claims about named people into his memory, and then
those claims get recalled as fact. For this capability a bad floor is worse than none, so the
shipped state is honest absence: `speaker.Disabled`, `ErrDisabled`, and a startup line saying
so.
## The refusals, and why
- **Unknown speakers are NOT enrolled on first interaction.** The plan's fourth "done when"
bullet asked for exactly that, with a TTS "кто это?" prompt. It is refused in
`enroll.go`'s doc comment and there is no request shape in the protocol that could express
it. Enrolling a voice is taking a biometric of a person; doing it automatically to whoever
walks past the microphone does it to guests who are not party to the exchange, and a
synthesised question into a room is not consent from whoever happens to answer. Enrolment is
an explicit act: an id, a name, and samples recorded for the purpose.
- **One sample is not enough.** Three separate utterances and nine seconds minimum. A profile
built from one sentence encodes that sentence as much as the person, and the threshold then
behaves unpredictably against everything else.
- **An unknown voice stays unknown.** Below threshold, `Identify` returns `ErrUnknown` naming
the closest profile in the error text for diagnosis, never as an answer. Guessing who is in
the room is how false memories about people get written.
- **Deletion is one authority rung below enrolment.** Everywhere else in `policy.go` the
destructive direction is gated at least as hard as the constructive one. Here that would be
backwards: getting rid of a biometric must never be the harder half.
- **The voiceprint never crosses the socket.** `ListSpeakersResp` carries ids, names, dates
and sample counts. The vector stays in core.
- **Off unless configured.** No `speaker` block ⇒ the three methods answer
`ErrUnknownMethod`. There is no wire path on a default box that takes a voiceprint.
## Storage
Profiles live in the existing `memory_vectors` table under the `speaker:` id prefix, as the
plan intended, so there is no migration. What that needed was a wider interface than
`memory.Store`: `memory.Catalog` adds `ByPrefix` and `Delete`. `Delete` is the load-bearing
one — a voiceprint someone asked to be rid of has to actually go, and a search-only store
cannot do that. `InMemoryStore.Insert` also became an upsert by id, matching what the
persistent store already did, so re-enrolling replaces a profile instead of stacking a second
one behind the first.
Profiles do not collide with note or fact vectors: they are only ever read through
`ByPrefix("speaker:")`, and a note search never returns one because the prefix is not in its
query path.
## Config
```json
"speaker": {
"enabled": true,
"model_path": "/opt/maven/models/spk/ecapa-voxceleb.onnx",
"lib_path": "/opt/maven/lib",
"threshold": 0.7,
"min_seconds": 2.0
}
```
`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.
## Still open
- The embedding backend (above). Everything below waits on it.
- **Tagging voice writes.** `Profile.Source("tap:voice")` already produces
`tap:voice:speaker:kami`, which is the shape step 6 asked for, but nothing calls it yet:
with no recogniser there is no id to tag with. When the model lands, the hook is in the
voice path before STT.
- **Speaker as router/phraser context.** Same dependency. Note the persona constraint when it
arrives: Maven addresses the owner informally and speaks to him, so "ok, <name>" needs care
for anyone who is not him.
- **An enrolment surface.** The three IPC methods exist; no page drives them. Enrolment is
step-up, so it belongs on `/dash` behind a passkey, with a per-profile forget button next to
each row — that button is the reason `list_speakers` exists.
- **A speaker column on the meeting recorder** (#253). Attributing lines in a transcript is
the obvious pairing, and it is the place where getting attribution wrong is most damaging,
so it waits for a real model too.
+25
View File
@@ -27,3 +27,28 @@
7. Add voice query handler — `"что нового?"` queries `RecentNotes` filtered by source prefix `rss:` and phrases via `phraser.PhraseQuery`
8. Add `feeds` block to `config.Config` and `deploy/mavend.json`
9. Test with a live RSS feed (e.g., `https://news.ycombinator.com/rss`) — verify items appear in notes table
---
## Shipped 2026-08-01 (#258)
`internal/webfetch` (the guarded HTTP door: scheme, allow/deny hosts, private-address
refusal in the dialer, size cap, redirect cap, per-host rate limit), `internal/rss`
(RSS 2.0 + Atom parser, poller with durable marks and a keyword filter),
`cmd/mavend/feeds.go` (ticker, fetcher adapter, `rss:latest:<feed>` fact marks),
config block `feeds`, and the `feeds` query source with `router.ParseFeedQuery`.
Deviations from the plan above, both deliberate:
- **Step 5 (breaking-news nudges) was not built.** A feed that dispatches is a nag,
and the one thing Maven is not is a nag. Items are read when asked and nowhere else.
If breaking news is ever wanted, it belongs behind the existing delivery policy
(severity, quiet hours, digest), not in the poller.
- **Step 3 (embedder relevance) is a seam, not an implementation.** `rss.Ranker`
exists and is wired nil. Scoring items against an "interest profile" needs a
profile, and there is none yet; a threshold with nothing to compare against is a
random filter with a confident name. The filter that runs is the per-feed
include/exclude keyword list, which he can read and predict.
No new dependency: stdlib `encoding/xml`, no gofeed. Stock deploy config has no
`feeds` block, so the capability is off.
+38
View File
@@ -28,3 +28,41 @@
7. Add IPC methods `MethodTriggerCrawl(name)`, `MethodListCrawls`, `MethodGetCrawlResult(name)`
8. Add `crawls` block to `config.Config` and `deploy/mavend.json`
9. Test with a static HTML page — verify extraction matches expected values, verify scheduling fires correctly
## Shipped 2026-08-01 (#259)
Built as `internal/crawl` (pure: robots, extraction, watcher) plus
`cmd/mavend/crawls.go` (fetcher, ticker, dedup facts), on top of the guarded
`internal/webfetch` door added with the feed reader (#258). Off unless
configured, in two separately-switched halves: `crawl.on_demand` for a URL he
names, `crawl.watches` for a scheduled re-read.
**Limits are code, not documentation** (`internal/webfetch`, tested one test per
limit): host allowlist/denylist, no private addresses (loopback, RFC1918 —
hence the LAN and the `10.42.0.0/24` wg range —, link-local incl. cloud
metadata, CGNAT, v6 ULA) enforced in the dialer's `Control` hook so DNS
rebinding and every redirect hop are covered, response size cap, redirect cap,
timeout, one request per host per second. `robots.txt` is fetched first, cached
per host, and a `Disallow` is refused with no override.
Deliberate deviations from the plan above:
- **No CSS selectors and no LLM structured extraction** (steps 2). The output is
plaintext handed to the phraser as context for the question he asked. A 1.7B
extracting a JSON price table from 4000 runes is a worse bet than reading, and
`goquery` is not vendored.
- **No `crawl` act verb and no new IPC methods** (steps 5, 7). Reading a page is
a query source (`queryWeb` in `actions_query.go`, last in the chain, behind
Kiwix once that is wired), not an action he commands. Nothing needs a new wire
method to work.
- **Notes, not facts.** A page's text is not a fact about him. Only the dedup
hash is a fact (`crawl:hash:<name>`, kind `config`, source `poll:crawl`).
- **Nothing is dispatched.** A changed page writes a note; it does not nudge.
Not a nag.
- **No `/tools` crawl history page.** The notes and the hash facts are already
visible on `/dash`.
**No new dependency.** The vendored tree has no `x/net/html`, no `goquery` and
no `temoto/robotstxt`, so robots parsing and HTML-to-text are stdlib
(`regexp`, `html`) — RE2 has no backreferences, hence the `pairsRE` builder in
`extract.go`.
+79
View File
@@ -393,3 +393,82 @@ func mustWriteFactParams(source string) []byte {
}
return b
}
// TestRequirement_SwapModel — loading a different resident model is an owner
// action at the same rung as mutating the tool allowlist: it decides how every
// utterance is routed and how every reply is worded. The read side is not.
func TestRequirement_SwapModel(t *testing.T) {
if got := Requirement(ipc.MethodSwapModel); got != AuthStepUp {
t.Errorf("SwapModel authority = %v; want AuthStepUp", got)
}
if got := Requirement(ipc.MethodModelStatus); got != AuthRead {
t.Errorf("ModelStatus authority = %v; want AuthRead", got)
}
// A surface that cannot carry a passkey gesture cannot swap the model, no
// matter what it is enrolled as — this is the "never through voice" property.
voice := Scope{Surface: SurfaceVoice, Module: "voice", SourceScope: []string{"*"}}
if err := Can(ipc.MethodSwapModel, voice, nil); !errors.Is(err, ErrForbidden) {
t.Errorf("voice swapping the model = %v; want ErrForbidden", err)
}
// And with no step-up session asserted, the gate refuses even a capable surface.
noSession := &Gate{Enrollment: NewFloorEnrollment()}
if err := noSession.Check(context.Background(), ipc.MethodSwapModel, nil); !errors.Is(err, ipc.ErrForbidden) {
t.Errorf("SwapModel with no asserted step-up = %v; want ErrForbidden", err)
}
}
// TestRequirement_Capture — recording other people is a write, not a read: it
// puts audio of them on disk. The read side, "что ты записываешь?", is not.
//
// It is deliberately NOT AuthStepUp. Step-up needs a passkey gesture, which the
// voice path cannot make, so putting it there would mean "запиши встречу" could
// never work by voice. The real gate on this capability is that the methods do
// not exist at all unless the operator enabled a capture block.
func TestRequirement_Capture(t *testing.T) {
for _, m := range []ipc.Method{
ipc.MethodCaptureStart, ipc.MethodCaptureAppend, ipc.MethodCaptureStop,
} {
if got := Requirement(m); got != AuthWrite {
t.Errorf("%s authority = %v; want AuthWrite", m, got)
}
}
if got := Requirement(ipc.MethodCaptureStatus); got != AuthRead {
t.Errorf("CaptureStatus authority = %v; want AuthRead", got)
}
// Voice can start one: it is the surface he will actually use to say
// "запиши встречу", and it carries AuthWrite.
voice := Scope{Surface: SurfaceVoice, Module: "voice", SourceScope: []string{"*"}}
if err := Can(ipc.MethodCaptureStart, voice, nil); err != nil {
t.Errorf("voice starting a capture = %v; want allowed", err)
}
}
// TestRequirement_Speaker — a voiceprint is a biometric of a named person, so
// taking one is step-up: a deliberate act from a surface that can carry a
// passkey gesture, never something the voice path does mid-conversation.
//
// Deletion is one rung lower, and that asymmetry is the point. Everywhere else
// in the table the destructive direction is gated at least as hard as the
// constructive one; for a biometric that would be backwards, because getting
// rid of it must never be the harder half.
func TestRequirement_Speaker(t *testing.T) {
if got := Requirement(ipc.MethodEnrollSpeaker); got != AuthStepUp {
t.Errorf("EnrollSpeaker authority = %v; want AuthStepUp", got)
}
if got := Requirement(ipc.MethodForgetSpeaker); got != AuthWrite {
t.Errorf("ForgetSpeaker authority = %v; want AuthWrite", got)
}
if got := Requirement(ipc.MethodListSpeakers); got != AuthRead {
t.Errorf("ListSpeakers authority = %v; want AuthRead", got)
}
// Voice cannot enrol anybody, however the utterance is phrased.
voice := Scope{Surface: SurfaceVoice, Module: "voice", SourceScope: []string{"*"}}
if err := Can(ipc.MethodEnrollSpeaker, voice, nil); err == nil {
t.Error("voice enrolling a speaker was allowed; want refused")
}
// But it can read the roster, which is what answering "кого ты знаешь?"
// needs.
if err := Can(ipc.MethodListSpeakers, voice, nil); err != nil {
t.Errorf("voice listing speakers = %v; want allowed", err)
}
}
+68 -1
View File
@@ -53,6 +53,44 @@ func Requirement(m ipc.Method) Authority {
// asserted — never a module or the voice/chat path. maven can propose
// (MethodProposeTool, no step-up: she has no passkey) but never en/disable.
return AuthStepUp
case ipc.MethodSwapModel:
// Swapping the resident model changes what routes every utterance and
// what words every reply. It is the owner's call, from a surface that can
// carry a passkey gesture — the same rung as mutating the tool allowlist,
// and for the same reason: nothing Maven says or does may reach it.
// MethodModelStatus is only the read side, so it stays at AuthRead.
return AuthStepUp
case ipc.MethodCaptureStart, ipc.MethodCaptureAppend, ipc.MethodCaptureStop:
// Recording a meeting (Vikunja #253). AuthWrite, not AuthRead: it puts
// audio of other people on disk, which is a heavier thing than reading a
// fact, and it is not something a read-only surface should be able to
// begin. Append and Stop sit on the same rung as Start deliberately —
// a surface that may not start a recording has no business feeding or
// harvesting one either.
//
// Not AuthStepUp, and this is the interesting line: step-up needs a
// passkey gesture, which the voice path cannot make. Putting it here
// would mean "запиши встречу" could never work by voice, and the real
// gate on this capability is elsewhere and stronger — the methods do not
// exist at all unless the operator enabled a capture block, and no
// recording can begin without someone saying so.
return AuthWrite
case ipc.MethodEnrollSpeaker:
// Taking a voiceprint (Vikunja #255). AuthStepUp, and unlike recording a
// meeting there is no reason to soften it: enrolment is not a thing anyone
// does by voice mid-conversation. It is a deliberate sit-down with a
// surface that can carry a passkey gesture, and it writes a biometric of a
// named person. If the gesture is inconvenient, that is the correct amount
// of friction for this particular write.
return AuthStepUp
case ipc.MethodForgetSpeaker:
// Deleting a voiceprint. One rung BELOW enrolment on purpose. Everywhere
// else in this table the destructive direction is gated at least as hard
// as the constructive one, and here that would be wrong: getting rid of a
// biometric must never be the harder half. The worst a caller at this rung
// can do is make Maven stop recognising someone, which is the state the
// box ships in anyway.
return AuthWrite
case ipc.MethodWriteFact:
return AuthWrite
case ipc.MethodAssertStepUp:
@@ -74,7 +112,36 @@ func Requirement(m ipc.Method) Authority {
// existing analogue.
ipc.MethodCaptureTask,
ipc.MethodListTasks,
ipc.MethodSetTaskStatus:
ipc.MethodSetTaskStatus,
// Mail ingestion (Vikunja #246). AuthRead because of what the method can
// produce: candidate tasks and nothing else. It cannot write a fact, set a
// reminder, or touch the tool allowlist, so a compromised mail reader can
// at worst put junk on a review page he clears in one click.
ipc.MethodIngestMail,
// Looking at one image (Vikunja #252). AuthRead because of what it can
// produce: words about a picture, and optionally a note. It cannot write
// a fact, set a reminder, or touch the tool allowlist. The invasive part
// of this capability is not the authority rung — it is that the bytes are
// kept on disk, which media.retention bounds, and that they never leave
// the box, which internal/vision enforces by refusing a non-private
// endpoint.
ipc.MethodDescribeImage,
// "что ты записываешь?" — the read side of the recorder. It reports a
// label, a start time and a byte count, begins nothing and keeps nothing.
ipc.MethodCaptureStatus,
// Who is enrolled. Returns ids, names and enrolment dates — never the
// voiceprints themselves, which stay in core. Listing the people Maven can
// recognise is exactly the read a surface needs to offer a "forget" button.
ipc.MethodListSpeakers,
// The read side of the model swap: which model is resident, which ones are
// allowlisted. It loads nothing and changes nothing.
ipc.MethodModelStatus,
// The unified intake journal (Vikunja #283). AuthRead, and listed
// explicitly rather than inherited so the reasoning is on the record: it
// reports what already arrived — sources, keys, note headlines — which is
// the same material RecentFacts and RecentNotes already return at this
// rung. It writes nothing, and it holds nothing a fact read does not.
ipc.MethodRecentEvents:
return AuthRead
}
// Unknown method ⇒ AuthRead, but ipc.dispatch returns ErrUnknownMethod
+404
View File
@@ -0,0 +1,404 @@
// Package capture is Maven's meeting recorder (Vikunja #253,
// docs/plans/08-hearing.md).
//
// One session at a time, with an explicit start and an explicit stop:
//
// Start("встреча") → audio frames appended → Stop() → transcript → summary
//
// # Nothing here listens
//
// This is the most invasive capability in the backlog and the design is
// constrained accordingly. The constraints are the code, not a preamble:
//
// - There is no ambient path. `Session.Append` is the only way audio enters,
// and it only accepts frames while a session someone started is running.
// A keyword-triggered recorder ("maven record" heard in the room) was in the
// plan document and is refused: it requires listening in order to notice the
// keyword, which is the exact behaviour this capability must not have.
// - A session that is not stopped stops itself. MaxDuration is a hard cap
// checked on every Append, not a suggestion; a forgotten recording is a
// recording that ends, not one that runs until the disk is full.
// - Audio is stored under internal/media, which means retention prunes it and
// it never leaves the box. Both the audio blob and the transcript stay
// local; only the summary is written where he will read it.
// - The transcript is never search input for anything outside this box. It is
// text about a conversation with other people in it.
//
// # Long audio against a 4096-token context
//
// The resident model is a Thinking variant at n_ctx 4096, so an hour of meeting
// transcript does not fit in one prompt and never will. summarize.go does the
// obvious map-reduce: split the transcript on sentence boundaries into windows
// that fit, summarise each, then summarise the summaries. That is handled
// explicitly rather than by truncation, because a truncated meeting summary is
// worse than none — it looks complete and is not.
//
// # Transcription
//
// There is exactly one STT in Maven and this package does not add a second: it
// takes an stt.Transcriber, which in deploy is the whisper.cpp worker behind
// cmd/mavsttd. Long audio is transcribed in windows too (see chunkAudio), for
// the same reason whisper itself works in 30s windows — handing a worker an hour
// of PCM in one call is a request that either times out or blocks everything
// else for minutes.
package capture
import (
"context"
"errors"
"fmt"
"strings"
"sync"
"time"
"github.com/kami/maven/internal/audio"
"github.com/kami/maven/internal/media"
"github.com/kami/maven/internal/stt"
)
// DefaultMaxDuration — how long one capture may run before it stops itself.
// Two hours covers a long meeting and bounds the damage of a forgotten session:
// at 16 kHz mono that is about 230 MB of PCM, which is over media's default
// per-blob cap, so a session at the limit is stored truncated rather than
// refused. That trade is deliberate — a partial recording of a meeting he asked
// for beats an error after two hours.
const DefaultMaxDuration = 2 * time.Hour
// DefaultSTTWindow — how much audio goes to the transcriber in one call. Five
// minutes of 16 kHz mono is under 10 MB, transcribes in well under whisper's
// own timeout on this box, and keeps the worker responsive to the voice path
// between windows.
const DefaultSTTWindow = 5 * time.Minute
// Errors callers distinguish.
var (
// ErrDisabled — capture is not configured. A capability is off unless
// configured, and a recorder most of all.
ErrDisabled = errors.New("capture: not configured")
// ErrBusy — a session is already running. One at a time: two concurrent
// recordings would make "хватит" ambiguous.
ErrBusy = errors.New("capture: a session is already running")
// ErrNoSession — stop or append with nothing running.
ErrNoSession = errors.New("capture: nothing is being recorded")
// ErrBadFormat — a frame is not the canonical 16 kHz mono PCM shape.
ErrBadFormat = errors.New("capture: audio format not supported")
// ErrEmptyCapture — the session ended with no audio in it.
ErrEmptyCapture = errors.New("capture: nothing was recorded")
// ErrExpired — the session hit MaxDuration and was closed. Returned from
// Append so the caller stops sending; the audio collected so far is kept.
ErrExpired = errors.New("capture: session reached its time limit")
)
// Session — one recording in progress. Not created directly; Recorder.Start
// makes it. Guarded by a mutex because frames arrive from a network goroutine
// while a status call may read from another.
type Session struct {
Label string
Started time.Time
mu sync.Mutex
pcm []byte
format audio.Format
expired bool
}
// Duration is how much audio has been collected, from the bytes rather than the
// wall clock: a stream that dropped frames should report the audio that exists,
// not the time that passed.
func (s *Session) Duration() time.Duration {
s.mu.Lock()
defer s.mu.Unlock()
return s.duration()
}
func (s *Session) duration() time.Duration {
a := audio.Audio{Format: s.format, Bytes: s.pcm}
return time.Duration(a.Duration() * float64(time.Second))
}
// Bytes is how much PCM has been collected. For a status line.
func (s *Session) Bytes() int {
s.mu.Lock()
defer s.mu.Unlock()
return len(s.pcm)
}
// Status — what a "что записываешь?" answer needs, and what /dash shows. It is
// the read side of a running session and is safe to ask for at any time.
type Status struct {
Running bool `json:"running"`
Label string `json:"label,omitempty"`
Started time.Time `json:"started,omitempty"`
Duration time.Duration `json:"duration,omitempty"`
Bytes int `json:"bytes,omitempty"`
}
// Recorder owns the single session slot, the blob store and the two models a
// finished capture needs. Build it with New; a zero Recorder is not usable.
type Recorder struct {
blobs *media.Store
tr stt.Transcriber
sum *Summarizer
maxDuration time.Duration
sttWindow time.Duration
now func() time.Time
mu sync.Mutex
current *Session
}
// Config — the recorder's knobs, built from config.CaptureConfig by the daemon.
type Config struct {
// MaxDuration — hard cap on one session. 0 ⇒ DefaultMaxDuration.
MaxDuration time.Duration
// STTWindow — audio per transcription call. 0 ⇒ DefaultSTTWindow.
STTWindow time.Duration
}
// New builds a Recorder. blobs and tr are required — a recorder with nowhere to
// put the audio, or nothing to transcribe it with, is not a recorder. sum may be
// nil: the transcript is still produced and stored, and the summary is simply
// absent, which is the honest degradation when there is no llama-server.
func New(blobs *media.Store, tr stt.Transcriber, sum *Summarizer, cfg Config) (*Recorder, error) {
if blobs == nil {
return nil, errors.New("capture: no blob store")
}
if tr == nil {
return nil, errors.New("capture: no transcriber")
}
maxDur := cfg.MaxDuration
if maxDur <= 0 {
maxDur = DefaultMaxDuration
}
window := cfg.STTWindow
if window <= 0 {
window = DefaultSTTWindow
}
return &Recorder{
blobs: blobs,
tr: tr,
sum: sum,
maxDuration: maxDur,
sttWindow: window,
now: time.Now,
}, nil
}
// MaxDuration is the configured hard cap. For the reply that tells him how long
// she will keep going if he forgets to say "хватит".
func (r *Recorder) MaxDuration() time.Duration { return r.maxDuration }
// Start opens a session. label is what the meeting is called ("встреча с
// подрядчиком"); it ends up in the summary note so the note is findable.
// ErrBusy if one is already running — the caller says so rather than silently
// discarding the first recording.
func (r *Recorder) Start(label string) (*Session, error) {
r.mu.Lock()
defer r.mu.Unlock()
if r.current != nil {
return nil, fmt.Errorf("%w: %q since %s", ErrBusy, r.current.Label,
r.current.Started.Format(time.Kitchen))
}
s := &Session{
Label: strings.TrimSpace(label),
Started: r.now().UTC(),
format: audio.PCM16kMono,
}
r.current = s
return s, nil
}
// Append adds one frame to the running session. ErrNoSession when nothing is
// running, which is the guard that makes an ambient path impossible: a stream
// arriving at a Recorder nobody started is refused frame by frame.
//
// ErrExpired once the session is at MaxDuration. The audio collected so far is
// kept and Stop still works — the cap ends the recording, it does not throw it
// away.
func (r *Recorder) Append(a audio.Audio) error {
if !a.Format.IsValid() {
return fmt.Errorf("%w: %+v", ErrBadFormat, a.Format)
}
r.mu.Lock()
s := r.current
r.mu.Unlock()
if s == nil {
return ErrNoSession
}
s.mu.Lock()
defer s.mu.Unlock()
if s.expired {
return ErrExpired
}
s.pcm = append(s.pcm, a.Bytes...)
if s.duration() >= r.maxDuration {
s.expired = true
return ErrExpired
}
return nil
}
// Status reports the running session, or Running=false.
func (r *Recorder) Status() Status {
r.mu.Lock()
s := r.current
r.mu.Unlock()
if s == nil {
return Status{}
}
return Status{
Running: true,
Label: s.Label,
Started: s.Started,
Duration: s.Duration(),
Bytes: s.Bytes(),
}
}
// Result — a finished capture.
type Result struct {
// BlobID — the stored audio, content-addressed. Empty only if storing failed.
BlobID string
// Label / Started / Duration — what was recorded and when.
Label string
Started time.Time
Duration time.Duration
// Transcript — the full text, joined across STT windows.
Transcript string
// Summary — the map-reduced summary, or empty when no summarizer was wired
// or the model failed. Empty summary with a non-empty transcript is a
// degraded success, not a failure: the words are there.
Summary string
// Chunks — how many windows the transcript was summarised in. 1 means it fit
// in one prompt. Reported so a suspiciously vague summary can be explained.
Chunks int
}
// Stop ends the session and produces the result: store the audio, transcribe it
// in windows, summarise it in windows. The session slot is freed before any of
// the slow work starts, so a stuck model cannot block the next recording.
//
// The order matters and is the same as vision's: the audio is stored FIRST. If
// transcription or summarisation fails, the recording is still on disk and can
// be run again; a meeting that happened once must not be lost to a model error.
func (r *Recorder) Stop(ctx context.Context) (Result, error) {
r.mu.Lock()
s := r.current
r.current = nil
r.mu.Unlock()
if s == nil {
return Result{}, ErrNoSession
}
s.mu.Lock()
pcm := s.pcm
format := s.format
s.mu.Unlock()
res := Result{Label: s.Label, Started: s.Started}
if len(pcm) == 0 {
return res, ErrEmptyCapture
}
full := audio.Audio{Format: format, Bytes: pcm}
res.Duration = time.Duration(full.Duration() * float64(time.Second))
// Stored as WAV, not headerless PCM: a blob on disk that `aplay` and whisper
// can both open without being told the format is worth 44 bytes.
wav, err := audio.WAVFromPCM(format, pcm)
if err != nil {
return res, fmt.Errorf("capture: wav: %w", err)
}
blob, err := r.blobs.Put(media.KindAudio, "audio/wav", "capture:meeting", wav)
if err != nil {
// Over the per-blob cap is the expected case for a very long meeting.
// Report it and keep going: a transcript without the audio still beats
// nothing, and the words are what he will read.
return res, fmt.Errorf("capture: store audio: %w", err)
}
res.BlobID = blob.ID
text, err := r.transcribe(ctx, full)
if err != nil {
return res, fmt.Errorf("capture: transcribe: %w", err)
}
res.Transcript = text
if strings.TrimSpace(text) == "" {
return res, ErrEmptyCapture
}
if r.sum == nil {
return res, nil
}
summary, chunks, err := r.sum.Summarize(ctx, s.Label, text)
res.Chunks = chunks
if err != nil {
// Degraded success: the transcript is real and stored, only the summary
// is missing. The caller writes the transcript note and says so.
return res, fmt.Errorf("capture: summarize: %w", err)
}
res.Summary = summary
return res, nil
}
// Abort throws the running session away without transcribing or storing it.
// This is what "забудь, не записывай" must map to: a recording someone changed
// their mind about leaves nothing behind, not a blob with a note saying it was
// abandoned. Returns whether anything was running.
func (r *Recorder) Abort() bool {
r.mu.Lock()
defer r.mu.Unlock()
if r.current == nil {
return false
}
r.current = nil
return true
}
// transcribe runs the transcriber over the audio in windows and joins the text.
// A window that fails is fatal: a summary of a meeting with a silent hole in the
// middle is a summary that misleads.
func (r *Recorder) transcribe(ctx context.Context, a audio.Audio) (string, error) {
windows := chunkAudio(a, r.sttWindow)
parts := make([]string, 0, len(windows))
for i, w := range windows {
text, _, err := r.tr.Transcribe(ctx, w)
if err != nil {
return "", fmt.Errorf("window %d/%d: %w", i+1, len(windows), err)
}
if t := strings.TrimSpace(text); t != "" {
parts = append(parts, t)
}
}
return strings.Join(parts, " "), nil
}
// chunkAudio splits audio into windows of at most window duration, cut on
// sample boundaries. A window shorter than one sample is impossible; audio
// shorter than one window comes back as a single element, so the caller never
// special-cases the short case.
func chunkAudio(a audio.Audio, window time.Duration) []audio.Audio {
bytesPerSample := a.Format.SampleBits / 8 * a.Format.Channels
if bytesPerSample <= 0 || a.Format.SampleRate <= 0 || window <= 0 {
return []audio.Audio{a}
}
per := int(window.Seconds()) * a.Format.SampleRate * bytesPerSample
if per <= 0 || len(a.Bytes) <= per {
return []audio.Audio{a}
}
var out []audio.Audio
for off := 0; off < len(a.Bytes); off += per {
end := off + per
if end > len(a.Bytes) {
end = len(a.Bytes)
}
// Never cut mid-sample: a split inside an int16 shifts every following
// sample by a byte and turns the tail of the window into noise.
end -= (end - off) % bytesPerSample
if end <= off {
break
}
out = append(out, audio.Audio{Format: a.Format, Bytes: a.Bytes[off:end]})
}
return out
}
+363
View File
@@ -0,0 +1,363 @@
package capture
import (
"context"
"errors"
"fmt"
"strings"
"testing"
"time"
"github.com/kami/maven/internal/audio"
"github.com/kami/maven/internal/media"
)
// fakeTranscriber returns a fixed phrase per call so a windowed transcription is
// visible in the joined output.
type fakeTranscriber struct {
calls int
err error
phrase string
}
func (f *fakeTranscriber) Transcribe(_ context.Context, a audio.Audio) (string, float64, error) {
f.calls++
if f.err != nil {
return "", 0, f.err
}
p := f.phrase
if p == "" {
p = "окно"
}
return fmt.Sprintf("%s%d", p, f.calls), 1.0, nil
}
// fakeCompleter records prompts and replies from a script.
type fakeCompleter struct {
replies []string
systems []string
users []string
err error
}
func (f *fakeCompleter) Complete(_ context.Context, system, user string) (string, error) {
f.systems = append(f.systems, system)
f.users = append(f.users, user)
if f.err != nil {
return "", f.err
}
if len(f.replies) == 0 {
return "итог", nil
}
r := f.replies[0]
f.replies = f.replies[1:]
return r, nil
}
// frame builds n seconds of silence in the canonical format.
func frame(seconds float64) audio.Audio {
n := int(seconds*16000) * 2
return audio.Audio{Format: audio.PCM16kMono, Bytes: make([]byte, n)}
}
func testRecorder(t *testing.T, tr *fakeTranscriber, sum *Summarizer, cfg Config) (*Recorder, *media.Store) {
t.Helper()
blobs, err := media.Open(t.TempDir(), 0, 0)
if err != nil {
t.Fatal(err)
}
r, err := New(blobs, tr, sum, cfg)
if err != nil {
t.Fatal(err)
}
return r, blobs
}
func TestNewRequiresStoreAndTranscriber(t *testing.T) {
blobs, err := media.Open(t.TempDir(), 0, 0)
if err != nil {
t.Fatal(err)
}
if _, err := New(nil, &fakeTranscriber{}, nil, Config{}); err == nil {
t.Error("recorder built with no blob store")
}
if _, err := New(blobs, nil, nil, Config{}); err == nil {
t.Error("recorder built with no transcriber")
}
}
// The invariant that matters most: audio arriving at a recorder nobody started
// is refused. There is no ambient path in.
func TestAppendWithoutStartIsRefused(t *testing.T) {
r, _ := testRecorder(t, &fakeTranscriber{}, nil, Config{})
if err := r.Append(frame(1)); !errors.Is(err, ErrNoSession) {
t.Fatalf("got %v, want ErrNoSession", err)
}
if r.Status().Running {
t.Error("a refused frame started a session")
}
}
func TestStopWithoutStartIsRefused(t *testing.T) {
r, _ := testRecorder(t, &fakeTranscriber{}, nil, Config{})
if _, err := r.Stop(context.Background()); !errors.Is(err, ErrNoSession) {
t.Fatalf("got %v, want ErrNoSession", err)
}
}
func TestOneSessionAtATime(t *testing.T) {
r, _ := testRecorder(t, &fakeTranscriber{}, nil, Config{})
if _, err := r.Start("встреча"); err != nil {
t.Fatal(err)
}
if _, err := r.Start("вторая"); !errors.Is(err, ErrBusy) {
t.Fatalf("got %v, want ErrBusy", err)
}
if _, err := r.Stop(context.Background()); !errors.Is(err, ErrEmptyCapture) {
t.Fatalf("empty stop: %v", err)
}
// The slot is free again after a stop, even a failed one.
if _, err := r.Start("третья"); err != nil {
t.Errorf("slot not released: %v", err)
}
}
func TestRoundTripStoresAudioTranscriptAndSummary(t *testing.T) {
tr := &fakeTranscriber{phrase: "совещание"}
sum := NewSummarizer(&fakeCompleter{replies: []string{"— решили купить насос"}}, 0, 0, nil)
r, blobs := testRecorder(t, tr, sum, Config{})
if _, err := r.Start("встреча с подрядчиком"); err != nil {
t.Fatal(err)
}
for i := 0; i < 3; i++ {
if err := r.Append(frame(2)); err != nil {
t.Fatal(err)
}
}
res, err := r.Stop(context.Background())
if err != nil {
t.Fatalf("stop: %v", err)
}
if res.BlobID == "" {
t.Error("no audio blob stored")
}
blob, data, err := blobs.Read(res.BlobID)
if err != nil {
t.Fatalf("blob unreadable: %v", err)
}
if blob.Kind != media.KindAudio || blob.Source != "capture:meeting" {
t.Errorf("blob metadata = %+v", blob)
}
if string(data[:4]) != "RIFF" {
t.Error("audio was not stored as a playable WAV")
}
if res.Transcript == "" {
t.Error("no transcript")
}
if !strings.Contains(res.Summary, "насос") {
t.Errorf("summary = %q", res.Summary)
}
if !strings.Contains(res.Summary, "встреча с подрядчиком") {
t.Errorf("label missing from summary: %q", res.Summary)
}
if res.Duration != 6*time.Second {
t.Errorf("duration = %v, want 6s", res.Duration)
}
}
// A forgotten session stops itself, and the audio collected before the cap is
// kept rather than thrown away.
func TestMaxDurationEndsTheSessionAndKeepsAudio(t *testing.T) {
tr := &fakeTranscriber{}
r, _ := testRecorder(t, tr, nil, Config{MaxDuration: 4 * time.Second})
if _, err := r.Start("длинная"); err != nil {
t.Fatal(err)
}
if err := r.Append(frame(3)); err != nil {
t.Fatalf("first frame: %v", err)
}
if err := r.Append(frame(3)); !errors.Is(err, ErrExpired) {
t.Fatalf("got %v, want ErrExpired", err)
}
// Further frames keep being refused, so a client that ignores the error
// cannot grow the recording past the cap.
if err := r.Append(frame(3)); !errors.Is(err, ErrExpired) {
t.Fatalf("post-expiry frame: %v", err)
}
res, err := r.Stop(context.Background())
if err != nil {
t.Fatalf("stop after expiry: %v", err)
}
if res.Duration != 6*time.Second {
t.Errorf("duration = %v, want the 6s collected before the cap", res.Duration)
}
}
func TestAppendRejectsWrongFormat(t *testing.T) {
r, _ := testRecorder(t, &fakeTranscriber{}, nil, Config{})
if _, err := r.Start("x"); err != nil {
t.Fatal(err)
}
bad := audio.Audio{Format: audio.Format{SampleRate: 44100, Channels: 2, SampleBits: 16, Encoding: "pcm_s16le"}, Bytes: make([]byte, 100)}
if err := r.Append(bad); !errors.Is(err, ErrBadFormat) {
t.Fatalf("got %v, want ErrBadFormat", err)
}
}
// "забудь, не записывай" must leave nothing behind — no blob, no transcript.
func TestAbortLeavesNothing(t *testing.T) {
tr := &fakeTranscriber{}
r, blobs := testRecorder(t, tr, nil, Config{})
if _, err := r.Start("зря начали"); err != nil {
t.Fatal(err)
}
if err := r.Append(frame(5)); err != nil {
t.Fatal(err)
}
if !r.Abort() {
t.Fatal("Abort reported nothing running")
}
if r.Status().Running {
t.Error("session survived Abort")
}
list, err := blobs.List(media.KindAudio)
if err != nil {
t.Fatal(err)
}
if len(list) != 0 {
t.Errorf("Abort stored %d blob(s)", len(list))
}
if tr.calls != 0 {
t.Errorf("Abort transcribed anyway (%d calls)", tr.calls)
}
if r.Abort() {
t.Error("second Abort reported a session")
}
}
func TestStatusReportsTheRunningSession(t *testing.T) {
r, _ := testRecorder(t, &fakeTranscriber{}, nil, Config{})
if got := r.Status(); got.Running {
t.Error("idle recorder reports running")
}
if _, err := r.Start("планёрка"); err != nil {
t.Fatal(err)
}
if err := r.Append(frame(10)); err != nil {
t.Fatal(err)
}
st := r.Status()
if !st.Running || st.Label != "планёрка" {
t.Fatalf("status = %+v", st)
}
if st.Duration != 10*time.Second {
t.Errorf("duration = %v", st.Duration)
}
if st.Bytes != 10*16000*2 {
t.Errorf("bytes = %d", st.Bytes)
}
}
// Long audio goes to the transcriber in windows: handing a whisper worker an
// hour of PCM in one call blocks the voice path for minutes.
func TestLongAudioIsTranscribedInWindows(t *testing.T) {
tr := &fakeTranscriber{}
r, _ := testRecorder(t, tr, nil, Config{STTWindow: 2 * time.Second})
if _, err := r.Start("длинная"); err != nil {
t.Fatal(err)
}
if err := r.Append(frame(9)); err != nil {
t.Fatal(err)
}
res, err := r.Stop(context.Background())
if err != nil {
t.Fatalf("stop: %v", err)
}
if tr.calls != 5 { // 2+2+2+2+1
t.Errorf("transcriber called %d times, want 5", tr.calls)
}
if !strings.Contains(res.Transcript, "окно5") {
t.Errorf("last window missing from transcript: %q", res.Transcript)
}
}
// A hole in the middle of a meeting summary would mislead, so a failed window is
// fatal — but the audio is already stored and re-runnable.
func TestTranscriptionFailureKeepsTheAudio(t *testing.T) {
tr := &fakeTranscriber{err: errors.New("whisper is down")}
r, blobs := testRecorder(t, tr, nil, Config{})
if _, err := r.Start("встреча"); err != nil {
t.Fatal(err)
}
if err := r.Append(frame(2)); err != nil {
t.Fatal(err)
}
res, err := r.Stop(context.Background())
if err == nil {
t.Fatal("transcription failure was not reported")
}
if res.BlobID == "" {
t.Fatal("no blob id to retry with")
}
if _, _, err := blobs.Read(res.BlobID); err != nil {
t.Errorf("audio was not kept: %v", err)
}
}
// No llama-server ⇒ transcript only. That is the honest degradation, not an
// error.
func TestNoSummarizerStillProducesATranscript(t *testing.T) {
r, _ := testRecorder(t, &fakeTranscriber{}, nil, Config{})
if _, err := r.Start("встреча"); err != nil {
t.Fatal(err)
}
if err := r.Append(frame(1)); err != nil {
t.Fatal(err)
}
res, err := r.Stop(context.Background())
if err != nil {
t.Fatalf("stop: %v", err)
}
if res.Transcript == "" {
t.Error("no transcript")
}
if res.Summary != "" {
t.Errorf("summary appeared from nowhere: %q", res.Summary)
}
}
// A summariser failure is a degraded success: the words exist and are returned.
func TestSummaryFailureStillReturnsTheTranscript(t *testing.T) {
sum := NewSummarizer(&fakeCompleter{err: errors.New("llama is down")}, 0, 0, nil)
r, _ := testRecorder(t, &fakeTranscriber{}, sum, Config{})
if _, err := r.Start("встреча"); err != nil {
t.Fatal(err)
}
if err := r.Append(frame(1)); err != nil {
t.Fatal(err)
}
res, err := r.Stop(context.Background())
if err == nil {
t.Fatal("summary failure was not reported")
}
if res.Transcript == "" {
t.Error("transcript lost to a summary failure")
}
}
func TestChunkAudioNeverCutsMidSample(t *testing.T) {
a := audio.Audio{Format: audio.PCM16kMono, Bytes: make([]byte, 16000*2*5+1)}
for _, w := range chunkAudio(a, 2*time.Second) {
if len(w.Bytes)%2 != 0 {
t.Fatalf("window of %d bytes cuts an int16 in half", len(w.Bytes))
}
}
}
func TestChunkAudioShortInputIsOneWindow(t *testing.T) {
a := frame(1)
if got := chunkAudio(a, time.Minute); len(got) != 1 {
t.Errorf("got %d windows, want 1", len(got))
}
}
+261
View File
@@ -0,0 +1,261 @@
package capture
import (
"context"
"errors"
"fmt"
"strings"
"unicode"
)
// DefaultChunkRunes — how much transcript goes into one summarisation prompt.
//
// The resident model runs at n_ctx 4096 and is a Thinking variant, so reasoning
// tokens need room too. Russian runs roughly 2.53 characters per token on a
// Qwen tokenizer, so 3000 runes is about 1100 tokens of transcript, leaving the
// prompt, the persona block, the reasoning and the answer comfortable space.
// This is the same reasoning internal/crawl used to land on 4000 runes, tightened
// because a meeting transcript is denser in named entities than a web page and
// the reduce step has to fit several summaries at once.
const DefaultChunkRunes = 3000
// DefaultMaxChunks — how many windows one meeting may be summarised in. Forty
// chunks at 3000 runes is roughly a two-hour meeting, which is MaxDuration; past
// that the transcript is truncated and the summary says so, because forty-one
// sequential model calls on this box is half an hour of work nobody is waiting
// through.
const DefaultMaxChunks = 40
// ErrNoSummary — the model returned nothing usable for every chunk.
var ErrNoSummary = errors.New("capture: model produced no summary")
// Completer is the one thing the summarizer needs from a model: text in, text
// out. It is an interface rather than an *llm.Client so this package stays pure
// and testable, and so the daemon can pass whatever it already has.
type Completer interface {
Complete(ctx context.Context, system, user string) (string, error)
}
// Summarizer turns a transcript into something worth reading. It is map-reduce
// and nothing cleverer: summarise each window, then summarise the summaries.
//
// Truncation was the alternative and is rejected. A truncated meeting summary
// reads as complete and is not, which is worse than no summary at all — he would
// act on it.
type Summarizer struct {
llm Completer
chunkRunes int
maxChunks int
// context is the persona/context block the daemon prepends to every prompt,
// or empty. Passed in rather than built here so this package does not import
// internal/persona and the feminine self-reference rules stay in one place.
context func() string
}
// NewSummarizer wires a summarizer. llm nil ⇒ nil Summarizer, which Recorder
// treats as "transcript only", the honest degradation with no llama-server.
// chunkRunes ≤ 0 ⇒ DefaultChunkRunes; maxChunks ≤ 0 ⇒ DefaultMaxChunks.
func NewSummarizer(llm Completer, chunkRunes, maxChunks int, contextBlock func() string) *Summarizer {
if llm == nil {
return nil
}
if chunkRunes <= 0 {
chunkRunes = DefaultChunkRunes
}
if maxChunks <= 0 {
maxChunks = DefaultMaxChunks
}
if contextBlock == nil {
contextBlock = func() string { return "" }
}
return &Summarizer{llm: llm, chunkRunes: chunkRunes, maxChunks: maxChunks, context: contextBlock}
}
// chunkPrompt — the map step. Deliberately plain: this is not Maven speaking to
// him, it is a model condensing text, so there is no first person in it at all
// and therefore nothing for the persona's gender rules to get wrong. The reply
// she gives him afterwards is phrased by the ordinary replier, which does carry
// the persona.
const chunkPrompt = `Ты обрабатываешь фрагмент расшифровки разговора.
Сожми его до 2-4 пунктов: о чём говорили, какие решения приняли, какие задачи назвали.
Без вступлений и выводов. Только по тексту — не придумывай того, чего в нём нет.
Если во фрагменте нет ничего содержательного, ответь одним словом: пусто.`
// reducePrompt — the reduce step. Same rules, over the chunk summaries.
const reducePrompt = `Ниже — конспекты фрагментов одной встречи, по порядку.
Собери из них один короткий итог: о чём была встреча, какие решения приняли, что кому делать.
Не повторяйся, не придумывай, не добавляй вступлений.`
// emptyMarker — what the map step answers for a chunk with nothing in it. Such
// chunks are dropped before the reduce step rather than padding it with noise.
const emptyMarker = "пусто"
// Summarize returns the summary and the number of chunks the transcript was
// split into. One chunk means it fit in a single prompt and the reduce step was
// skipped, which is the common case for a short meeting and saves a model call.
func (s *Summarizer) Summarize(ctx context.Context, label, transcript string) (string, int, error) {
if s == nil {
return "", 0, ErrDisabled
}
chunks := ChunkText(transcript, s.chunkRunes)
if len(chunks) == 0 {
return "", 0, ErrEmptyCapture
}
truncated := false
if len(chunks) > s.maxChunks {
chunks = chunks[:s.maxChunks]
truncated = true
}
system := s.context() + chunkPrompt
parts := make([]string, 0, len(chunks))
for i, c := range chunks {
out, err := s.llm.Complete(ctx, system, c)
if err != nil {
return "", len(chunks), fmt.Errorf("chunk %d/%d: %w", i+1, len(chunks), err)
}
out = strings.TrimSpace(out)
if out == "" || strings.EqualFold(out, emptyMarker) {
continue
}
parts = append(parts, out)
}
if len(parts) == 0 {
return "", len(chunks), ErrNoSummary
}
summary := parts[0]
if len(parts) > 1 {
joined := strings.Join(parts, "\n\n")
reduced, err := s.llm.Complete(ctx, s.context()+reducePrompt, joined)
if err != nil {
// The per-chunk summaries are real work; hand them over rather than
// losing them to a failure in the last step.
return joined, len(chunks), fmt.Errorf("reduce: %w", err)
}
if r := strings.TrimSpace(reduced); r != "" {
summary = r
} else {
summary = joined
}
}
if label != "" {
summary = label + "\n\n" + summary
}
if truncated {
// Said in the note, not swallowed: a summary that silently covers the
// first hour of a three-hour meeting is the failure mode this guards.
summary += fmt.Sprintf("\n\n(расшифровка обрезана: обработано %d фрагментов из большего числа)", s.maxChunks)
}
return summary, len(chunks), nil
}
// ChunkText splits text into windows of at most maxRunes runes, cutting on
// sentence boundaries where it can and on a word boundary otherwise. Exported
// because it is the part worth testing on its own and the part a future
// transcript viewer will want.
//
// A sentence longer than maxRunes (a transcript with no punctuation at all,
// which whisper does produce) is cut on whitespace rather than dropped or run
// past the limit.
func ChunkText(text string, maxRunes int) []string {
text = strings.TrimSpace(text)
if text == "" {
return nil
}
if maxRunes <= 0 {
maxRunes = DefaultChunkRunes
}
if len([]rune(text)) <= maxRunes {
return []string{text}
}
var out []string
var cur []rune
flush := func() {
if s := strings.TrimSpace(string(cur)); s != "" {
out = append(out, s)
}
cur = cur[:0]
}
for _, sent := range splitSentences(text) {
sr := []rune(sent)
if len(sr) > maxRunes {
// Oversized sentence: emit what is buffered, then cut this one on
// word boundaries.
flush()
for _, piece := range splitWords(sr, maxRunes) {
out = append(out, piece)
}
continue
}
if len(cur)+len(sr) > maxRunes {
flush()
}
cur = append(cur, sr...)
}
flush()
return out
}
// splitSentences cuts on sentence-ending punctuation followed by a space,
// keeping the punctuation with the sentence it ends. Good enough for a
// transcript: whisper emits periods and question marks, and being wrong about an
// abbreviation costs a slightly uneven chunk, nothing more.
func splitSentences(text string) []string {
runes := []rune(text)
var out []string
start := 0
for i := 0; i < len(runes); i++ {
if runes[i] != '.' && runes[i] != '!' && runes[i] != '?' && runes[i] != '\n' {
continue
}
// Consume a run of punctuation ("?!", "...") so it stays together.
j := i
for j+1 < len(runes) && isSentenceEnd(runes[j+1]) {
j++
}
if j+1 < len(runes) && !unicode.IsSpace(runes[j+1]) {
i = j
continue
}
end := j + 1
for end < len(runes) && unicode.IsSpace(runes[end]) {
end++
}
out = append(out, string(runes[start:end]))
start = end
i = end - 1
}
if start < len(runes) {
out = append(out, string(runes[start:]))
}
return out
}
func isSentenceEnd(r rune) bool {
return r == '.' || r == '!' || r == '?'
}
// splitWords cuts an oversized run on whitespace, falling back to a hard cut
// when a single "word" is itself longer than the limit.
func splitWords(runes []rune, maxRunes int) []string {
var out []string
for len(runes) > maxRunes {
cut := maxRunes
for cut > 0 && !unicode.IsSpace(runes[cut]) {
cut--
}
if cut == 0 {
cut = maxRunes
}
if s := strings.TrimSpace(string(runes[:cut])); s != "" {
out = append(out, s)
}
runes = runes[cut:]
}
if s := strings.TrimSpace(string(runes)); s != "" {
out = append(out, s)
}
return out
}
+244
View File
@@ -0,0 +1,244 @@
package capture
import (
"context"
"errors"
"fmt"
"strings"
"testing"
)
func TestNilSummarizerWithoutAModel(t *testing.T) {
if s := NewSummarizer(nil, 0, 0, nil); s != nil {
t.Fatal("a summarizer with no model is not nil")
}
var s *Summarizer
if _, _, err := s.Summarize(context.Background(), "x", "текст"); !errors.Is(err, ErrDisabled) {
t.Fatalf("got %v, want ErrDisabled", err)
}
}
// The common case: a short meeting fits in one prompt, so there is exactly one
// model call and no reduce step.
func TestShortTranscriptSkipsTheReduceStep(t *testing.T) {
f := &fakeCompleter{replies: []string{"— договорились о смете"}}
s := NewSummarizer(f, 0, 0, nil)
out, chunks, err := s.Summarize(context.Background(), "смета", "Обсудили смету. Решили подписать.")
if err != nil {
t.Fatal(err)
}
if chunks != 1 {
t.Errorf("chunks = %d, want 1", chunks)
}
if len(f.users) != 1 {
t.Fatalf("%d model calls, want 1", len(f.users))
}
if !strings.Contains(out, "смете") || !strings.HasPrefix(out, "смета") {
t.Errorf("summary = %q", out)
}
}
func TestLongTranscriptIsMappedThenReduced(t *testing.T) {
f := &roleCompleter{mapReply: "часть", reduceReply: "общий итог"}
s := NewSummarizer(f, 40, 0, nil)
long := strings.Repeat("Говорили про насос и трубы. ", 12)
out, chunks, err := s.Summarize(context.Background(), "", long)
if err != nil {
t.Fatal(err)
}
if chunks < 2 {
t.Fatalf("chunks = %d, want the transcript split", chunks)
}
// One map call per chunk, then exactly one reduce.
if f.maps != chunks {
t.Errorf("%d map calls for %d chunks", f.maps, chunks)
}
if f.reduces != 1 {
t.Errorf("%d reduce calls, want 1", f.reduces)
}
if out != "общий итог" {
t.Errorf("summary = %q, want the reduced text", out)
}
}
// Losing every per-chunk summary because the last call failed would throw away
// most of the work.
func TestReduceFailureReturnsTheJoinedParts(t *testing.T) {
f := &roleCompleter{mapReply: "часть", reduceFails: true}
s := NewSummarizer(f, 40, 0, nil)
long := strings.Repeat("Говорили про насос и трубы. ", 12)
out, _, err := s.Summarize(context.Background(), "", long)
if err == nil {
t.Fatal("reduce failure was not reported")
}
if !strings.Contains(out, "часть1") || !strings.Contains(out, "часть2") {
t.Errorf("per-chunk work was lost: %q", out)
}
}
func TestChunkFailureIsReported(t *testing.T) {
f := &fakeCompleter{err: errors.New("llama is down")}
s := NewSummarizer(f, 0, 0, nil)
if _, _, err := s.Summarize(context.Background(), "", "текст"); err == nil {
t.Fatal("chunk failure was not reported")
}
}
// "пусто" chunks are noise; they must not pad the reduce prompt, and a
// transcript that is entirely empty chunks is an honest ErrNoSummary rather than
// an invented summary.
func TestEmptyChunksAreDropped(t *testing.T) {
f := &roleCompleter{mapReply: "пусто", literalMap: true, reduceReply: "не должно вызываться"}
s := NewSummarizer(f, 40, 0, nil)
long := strings.Repeat("Тишина в комнате. ", 12)
if _, _, err := s.Summarize(context.Background(), "", long); !errors.Is(err, ErrNoSummary) {
t.Fatalf("got %v, want ErrNoSummary", err)
}
}
func TestEmptyTranscriptIsRefused(t *testing.T) {
s := NewSummarizer(&fakeCompleter{}, 0, 0, nil)
if _, _, err := s.Summarize(context.Background(), "", " \n "); !errors.Is(err, ErrEmptyCapture) {
t.Fatalf("got %v, want ErrEmptyCapture", err)
}
}
// A summary that silently covers the first fraction of a long meeting is the
// failure mode; it has to say so.
func TestTruncationIsStatedInTheSummary(t *testing.T) {
f := &fakeCompleter{replies: []string{"a", "b", "итог"}}
s := NewSummarizer(f, 30, 2, nil)
long := strings.Repeat("Говорили про насос и про трубы. ", 20)
out, chunks, err := s.Summarize(context.Background(), "", long)
if err != nil {
t.Fatal(err)
}
if chunks != 2 {
t.Errorf("chunks = %d, want the cap of 2", chunks)
}
if !strings.Contains(out, "обрезана") {
t.Errorf("truncation not stated: %q", out)
}
}
// The persona block belongs to the daemon, not this package, and must reach the
// model when it is supplied.
func TestContextBlockIsPrependedToEveryPrompt(t *testing.T) {
f := &fakeCompleter{replies: []string{"итог"}}
s := NewSummarizer(f, 0, 0, func() string { return "ПЕРСОНА\n\n" })
if _, _, err := s.Summarize(context.Background(), "", "Обсудили смету."); err != nil {
t.Fatal(err)
}
for i, sys := range f.systems {
if !strings.HasPrefix(sys, "ПЕРСОНА") {
t.Errorf("call %d lost the context block: %q", i, sys)
}
}
}
// The map/reduce prompts must contain no first person at all: the persona's
// feminine forms live in the replier, and a first-person instruction here is a
// place for the model to write "я рад".
func TestPromptsHaveNoFirstPerson(t *testing.T) {
for name, p := range map[string]string{"chunk": chunkPrompt, "reduce": reducePrompt} {
for _, bad := range []string{" я ", "рад", "поняла", "мне ", "вы ", "ваш"} {
if strings.Contains(strings.ToLower(" "+p+" "), bad) {
t.Errorf("%s prompt contains %q", name, bad)
}
}
}
}
func TestChunkTextSplitsOnSentenceBoundaries(t *testing.T) {
text := "Раз два три. Четыре пять шесть. Семь восемь девять."
got := ChunkText(text, 20)
if len(got) != 3 {
t.Fatalf("got %d chunks: %q", len(got), got)
}
for _, c := range got {
if !strings.HasSuffix(c, ".") {
t.Errorf("chunk does not end on a sentence: %q", c)
}
}
}
func TestChunkTextPacksSentencesUpToTheLimit(t *testing.T) {
text := "Раз. Два. Три. Четыре."
got := ChunkText(text, 12)
if len(got) < 2 {
t.Fatalf("nothing was split: %q", got)
}
for _, c := range got {
if n := len([]rune(c)); n > 12 {
t.Errorf("chunk of %d runes exceeds the limit: %q", n, c)
}
}
}
// whisper does emit long unpunctuated runs; those must be cut on whitespace, not
// dropped and not run past the context limit.
func TestChunkTextCutsUnpunctuatedRuns(t *testing.T) {
text := strings.TrimSpace(strings.Repeat("слово ", 50))
got := ChunkText(text, 30)
if len(got) < 2 {
t.Fatalf("unpunctuated run was not split: %d chunks", len(got))
}
total := 0
for _, c := range got {
if n := len([]rune(c)); n > 30 {
t.Errorf("chunk of %d runes exceeds the limit", n)
}
total += strings.Count(c, "слово")
}
if total != 50 {
t.Errorf("%d of 50 words survived chunking", total)
}
}
// A single token longer than the window must still come out, hard-cut.
func TestChunkTextHandlesOneOversizedWord(t *testing.T) {
text := strings.Repeat("я", 70)
got := ChunkText(text, 20)
if len(got) != 4 {
t.Fatalf("got %d chunks, want 4", len(got))
}
if joined := strings.Join(got, ""); len([]rune(joined)) != 70 {
t.Errorf("%d runes survived, want 70", len([]rune(joined)))
}
}
func TestChunkTextShortInputAndEmpty(t *testing.T) {
if got := ChunkText("коротко", 100); len(got) != 1 || got[0] != "коротко" {
t.Errorf("got %q", got)
}
if got := ChunkText(" ", 100); got != nil {
t.Errorf("blank text produced %q", got)
}
}
// roleCompleter answers by which prompt it was handed, so a test does not have
// to predict how many chunks the text splits into. Map replies are numbered
// ("часть1", "часть2", …) unless literalMap is set.
type roleCompleter struct {
mapReply string
literalMap bool
reduceReply string
reduceFails bool
maps int
reduces int
}
func (f *roleCompleter) Complete(_ context.Context, system, _ string) (string, error) {
if strings.Contains(system, "конспекты фрагментов") {
f.reduces++
if f.reduceFails {
return "", errors.New("llama fell over")
}
return f.reduceReply, nil
}
f.maps++
if f.literalMap {
return f.mapReply, nil
}
return fmt.Sprintf("%s%d", f.mapReply, f.maps), nil
}
+535 -1
View File
@@ -18,11 +18,14 @@ import (
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/kami/maven/internal/delivery/ntfysink"
"github.com/kami/maven/internal/delivery/telegramsink"
"github.com/kami/maven/internal/mcp"
"github.com/kami/maven/internal/morning"
"github.com/kami/maven/internal/update"
"github.com/robfig/cron/v3"
)
@@ -108,6 +111,16 @@ type Config struct {
// calls its /v1/chat/completions endpoint to phrase nudges and reminders.
Phraser *PhraserConfig `json:"phraser,omitempty"`
// Update — how THIS box deploys a new build of Maven (Vikunja #249). nil ⇒
// the update capability does not exist, which is the state to leave it in
// unless the operator has read internal/update's package comment.
//
// mavend never reads this block: the daemon does not import internal/update
// and cannot update itself. It lives here because cmd/mavupdate — a CLI the
// owner runs on the host, the only trigger there is — reads the same config
// file to find the socket it health-checks.
Update *update.Config `json:"update,omitempty"`
// Voice — the client↔core surface + the stt/tts modules the daemon
// wires. nil ⇒ the daemon doesn't wire voice: the TCP listener stays
// down, the dispatcher's Voice slot stays nil (the routing table's
@@ -150,6 +163,35 @@ type Config struct {
// absent ⇒ no evaluation loop at all. See MemoryEvalConfig.
MemoryEval *MemoryEvalConfig `json:"memory_eval,omitempty"`
// Email — mail ingestion (Vikunja #246). nil / absent ⇒ core refuses
// ipc.MethodIngestMail outright, so a mail reader cannot make Maven read a
// mailbox by merely existing. See EmailConfig; the IMAP host and credential
// live in the reader (cmd/mavmaild), never here.
Email *EmailConfig `json:"email,omitempty"`
// IntakeJournal — how many entries the unified intake journal keeps
// (Vikunja #283): one envelope per thing that arrived, whatever direction it
// came from. Absent ⇒ DefaultIntakeJournal. A NEGATIVE value turns the
// journal off entirely, and then there is no decorator on the intake path at
// all.
//
// Not gated behind an "off unless configured" block like feeds or telegram,
// and the distinction is the one CLAUDE.md draws: that rule exists for
// capabilities that reach OUT — a fetch, a send, a third party. This reaches
// nowhere. It is a bounded in-memory log of writes core already performed,
// it is read only by /events and the simulator, and nothing Maven says
// depends on it.
IntakeJournal int `json:"intake_journal,omitempty"`
// Feeds — RSS/Atom feed reading (Vikunja #258). nil / absent ⇒ no feed is
// ever fetched: reading the outside world is off unless configured, like
// the weather and telegram. See FeedsConfig.
Feeds *FeedsConfig `json:"feeds,omitempty"`
// Crawl — reading a web page (Vikunja #259). nil / absent ⇒ Maven never
// fetches a page: not on request, not on a schedule. See CrawlConfig.
Crawl *CrawlConfig `json:"crawl,omitempty"`
// Praxis — the ecosystem attention-state service. When configured, maven
// calls the Praxis HTTP tools API for attention listing and item lifecycle.
// Maven never touches Praxis's database directly (ecosystem invariant: no
@@ -165,6 +207,142 @@ type Config struct {
// discovers and executes capabilities through Hexis for ecosystem actions.
// nil ⇒ no capability-aware routing.
Hexis *HexisConfig `json:"hexis,omitempty"`
// Vision — image understanding (Vikunja #252). nil / absent ⇒ she cannot
// look at pictures at all: the intake refuses, and no vision server is
// contacted. See VisionConfig.
Vision *VisionConfig `json:"vision,omitempty"`
// Media — where images and captured audio are kept on disk, and for how
// long. nil / absent ⇒ no blob store is wired, which is what disables both
// vision intake and meeting capture regardless of their own blocks: nothing
// in this repo holds a recording only in memory. See MediaConfig.
Media *MediaConfig `json:"media,omitempty"`
// Capture — meeting recording and summarisation (Vikunja #253). nil /
// absent ⇒ the recorder does not exist: the start/stop methods are not
// served at all, so nothing on this box can begin a recording. This is the
// most invasive capability Maven has and it is the one most firmly off by
// default. See CaptureConfig.
Capture *CaptureConfig `json:"capture,omitempty"`
// Speaker — voice identification (Vikunja #255). nil / absent ⇒ no
// voiceprint is ever computed and nobody can be enrolled. Enabling it needs
// a speaker-embedding model, which is not on this box. See SpeakerConfig.
Speaker *SpeakerConfig `json:"speaker,omitempty"`
// MCP — Model Context Protocol servers Maven connects OUT to (Vikunja
// #251). nil / absent / no enabled server ⇒ no connection is made and no
// tool is discovered, like every other capability that reaches outside the
// box. She is a client here, never a server: nothing exposes her own
// capabilities to an outside caller. See MCPConfig.
MCP *MCPConfig `json:"mcp,omitempty"`
}
// MCPConfig — the MCP client block. Servers are dark until one has
// `"enabled": true`, and a discovered tool is only ever PROPOSED: Kami enables
// it on /tools, on the authed surface, exactly as he would a shell tool. The
// voice path can never grant a capability to itself.
type MCPConfig struct {
// Servers — the configured servers. Each needs exactly one of command
// (a subprocess on this box) or url (a streamable-HTTP endpoint).
Servers []MCPServerConfig `json:"servers,omitempty"`
// Timeout — per-call budget for every server that does not set its own.
// 0 ⇒ mcp.DefaultTimeout (15s). A tool slower than this is not usable in a
// spoken turn.
Timeout Duration `json:"timeout,omitempty"`
// AllowHosts / DenyHosts — the host lists for the shared webfetch door that
// url servers go through. Deny wins. Private addresses are refused
// unconditionally unless the individual server sets allow_private.
AllowHosts []string `json:"allow_hosts,omitempty"`
DenyHosts []string `json:"deny_hosts,omitempty"`
// MaxBytes — cap on one JSON-RPC response. 0 ⇒ webfetch.DefaultMaxBytes.
MaxBytes int64 `json:"max_bytes,omitempty"`
}
// MCPServerConfig — one MCP server.
type MCPServerConfig struct {
// Name — the local handle. It prefixes every tool this server contributes
// ("vikunja" + "list_tasks" ⇒ the allowlist row "vikunja_list_tasks") and
// becomes the store scope "mcp:<name>", so its provenance is readable on
// /tools without opening the config.
Name string `json:"name"`
// Command / Args / Env / Dir — a stdio server: a child process of mavend,
// on this box, under this user. argv, never a shell string.
Command string `json:"command,omitempty"`
Args []string `json:"args,omitempty"`
Env []string `json:"env,omitempty"`
Dir string `json:"dir,omitempty"`
// URL — a streamable-HTTP endpoint. It is fetched through
// internal/webfetch, so the SSRF guard, the redirect cap, the size cap and
// the one-request-per-host-per-second limit all apply.
URL string `json:"url,omitempty"`
// AllowPrivate — let THIS server be a loopback or LAN address. The Vikunja
// server on homesrv is "http://localhost:9100/mcp", which is refused
// without this flag. Understand what it means before setting it: a local
// server is a DIFFERENT trust level from a public one. It is inside the
// network, it usually needs no credential, and it can change things that
// matter — so an argument the router got wrong lands somewhere real. Set it
// only for a server you run yourself, and prefer allow_tools with it.
AllowPrivate bool `json:"allow_private,omitempty"`
// AllowTools — when set, the ONLY remote tool names taken from this server.
// This is the knob that keeps the catalogue deliberate: the resident model
// is a 1.7B with a 4096-token context, and a tool name it half-remembers is
// a wrong act, so fewer and better-chosen beats complete.
AllowTools []string `json:"allow_tools,omitempty"`
// MaxTools — cap on this server's contribution. 0 ⇒ mcp.DefaultMaxTools (12).
MaxTools int `json:"max_tools,omitempty"`
// Timeout — per-call budget for this server. 0 ⇒ MCPConfig.Timeout.
Timeout Duration `json:"timeout,omitempty"`
// Enabled — false (the default) keeps a configured server described but
// dark, so a block can be written and reviewed before it is switched on.
Enabled bool `json:"enabled,omitempty"`
}
// MCPServers maps the config blocks onto the mcp package's own type. It lives
// here so config validation and daemon wiring cannot drift on the mapping.
// Returns nil when nothing is configured or nothing is enabled.
func (c *Config) MCPServers() []mcp.ServerConfig {
if c.MCP == nil {
return nil
}
out := make([]mcp.ServerConfig, 0, len(c.MCP.Servers))
for _, s := range c.MCP.Servers {
if !s.Enabled {
continue
}
timeout := time.Duration(s.Timeout)
if timeout <= 0 {
timeout = time.Duration(c.MCP.Timeout)
}
out = append(out, mcp.ServerConfig{
Name: s.Name,
Command: s.Command,
Args: s.Args,
Env: s.Env,
Dir: s.Dir,
URL: s.URL,
AllowPrivate: s.AllowPrivate,
AllowTools: s.AllowTools,
MaxTools: s.MaxTools,
Timeout: timeout,
Enabled: true,
})
}
if len(out) == 0 {
return nil
}
return out
}
// PraxisConfig — maven's connection to the Praxis attention service.
@@ -334,6 +512,171 @@ type VoiceConfig struct {
ToolTimeout Duration `json:"tool_timeout,omitempty"`
}
// MediaConfig — the on-disk blob store for images and captured audio
// (internal/media). It is shared by all three senses: vision intake, meeting
// capture, and speaker enrolment samples all write here.
//
// Absent ⇒ off, and off means Maven cannot accept an image or start a recording
// at all. That default is deliberate: a capability that keeps photos and audio of
// people on disk should require someone to have typed a path.
type MediaConfig struct {
// Dir — the blob store root, created 0700. Relative paths resolve against
// StateDir. Required; an empty dir means the store is not wired.
Dir string `json:"dir,omitempty"`
// Retention — how long a blob is kept before the tick prunes it. 0 ⇒
// media.DefaultRetention (7 days). This is the knob that stops recordings
// of people accumulating; raising it past a few weeks should need a reason.
Retention Duration `json:"retention,omitempty"`
// MaxBytes — per-blob cap. 0 ⇒ media.DefaultMaxBytes (64 MiB).
MaxBytes int64 `json:"max_bytes,omitempty"`
}
// StoreDir reports the configured blob directory, or "" when media is not
// wired. Safe on a nil receiver.
func (m *MediaConfig) StoreDir() string {
if m == nil {
return ""
}
return strings.TrimSpace(m.Dir)
}
// VisionConfig — the vision provider (internal/vision, docs/plans/07-vision.md).
//
// Absent, or enabled=false, ⇒ the daemon wires vision.Disabled and every attempt
// to look at an image answers that vision is not set up. There is no cloud
// option in this block on purpose: Endpoint must be a loopback or private
// address and internal/vision refuses anything else at startup, because
// inference stays on the box and a photo of his flat is the last thing to make
// an exception for.
type VisionConfig struct {
// Enabled — may she look at images. Default false.
Enabled bool `json:"enabled,omitempty"`
// Endpoint — base URL of a llama-server running a vision model with its
// mmproj, e.g. "http://127.0.0.1:8081". Loopback / private only.
Endpoint string `json:"endpoint,omitempty"`
// Model — model name sent in the request. llama-server ignores it.
Model string `json:"model,omitempty"`
// MaxDim — longest edge the image is scaled to before inference. 0 ⇒
// media.DefaultMaxDim (896).
MaxDim int `json:"max_dim,omitempty"`
// MaxTokens — cap on the description. 0 ⇒ vision.DefaultMaxTokens (300).
MaxTokens int `json:"max_tokens,omitempty"`
// Timeout — per-description budget. 0 ⇒ vision.DefaultTimeout (90s). A small
// VLM on an iGPU is slow; a tight timeout here just means no answer ever.
Timeout Duration `json:"timeout,omitempty"`
// Prompt — the default question when he only sent a picture. Empty ⇒
// vision.DefaultPrompt (Russian, "опиши что на изображении").
Prompt string `json:"prompt,omitempty"`
}
// LooksAtImages reports whether vision is configured well enough to try. Safe on
// a nil receiver, and false without an endpoint — enabled with nothing to talk
// to is a misconfiguration, not a capability.
func (v *VisionConfig) LooksAtImages() bool {
return v != nil && v.Enabled && strings.TrimSpace(v.Endpoint) != ""
}
// CaptureConfig — the meeting recorder (internal/capture,
// docs/plans/08-hearing.md).
//
// Absent, or enabled=false, ⇒ the recorder is not wired and the capture methods
// return "unknown method", so no client can start a recording however it asks.
// A media block is required too: audio is never held only in memory.
//
// There is deliberately no "auto", no keyword trigger and no duration default
// long enough to be forgotten about. Recording other people is an explicit act
// with a start, a stop, and a cap.
type CaptureConfig struct {
// Enabled — may she record a meeting when asked. Default false.
Enabled bool `json:"enabled,omitempty"`
// MaxMinutes — hard cap on one session; it stops itself there. 0 ⇒
// capture.DefaultMaxDuration (120 minutes).
MaxMinutes int `json:"max_minutes,omitempty"`
// STTWindow — audio handed to whisper per call. 0 ⇒
// capture.DefaultSTTWindow (5m). Larger windows transcribe slightly better
// and block the STT worker for longer.
STTWindow Duration `json:"stt_window,omitempty"`
// ChunkRunes — transcript runes per summarisation prompt. 0 ⇒
// capture.DefaultChunkRunes (3000), sized for the resident model's n_ctx of
// 4096. Raise this only if the resident model's context grows.
ChunkRunes int `json:"chunk_runes,omitempty"`
// MaxChunks — how many windows one meeting may be summarised in before the
// transcript is truncated and the summary says so. 0 ⇒
// capture.DefaultMaxChunks (40).
MaxChunks int `json:"max_chunks,omitempty"`
// SaveTranscript — write the full transcript as a note alongside the
// summary. Default false: a verbatim record of what other people said in a
// room is a heavier thing to keep than a four-line summary, so it takes a
// deliberate yes. The audio blob is pruned by media.retention either way.
SaveTranscript bool `json:"save_transcript,omitempty"`
}
// Records reports whether the recorder should be wired. Safe on a nil receiver.
func (c *CaptureConfig) Records() bool {
return c != nil && c.Enabled
}
// MaxDuration is the configured session cap as a duration, or 0 for the
// package default. Safe on a nil receiver.
func (c *CaptureConfig) MaxDuration() time.Duration {
if c == nil || c.MaxMinutes <= 0 {
return 0
}
return time.Duration(c.MaxMinutes) * time.Minute
}
// SpeakerConfig — voice identification (internal/speaker,
// docs/plans/10-speaker-recognition.md).
//
// Absent, or enabled=false, ⇒ no voiceprint is computed for any turn, the
// enrolment methods do not exist, and nobody can be enrolled. A voiceprint is
// biometric data about a person, so this one is off until someone typed a model
// path on purpose.
//
// It cannot currently be turned on: there is no speaker-embedding model on this
// box. See the plan document for what to download.
type SpeakerConfig struct {
// Enabled — may she work out who is speaking. Default false.
Enabled bool `json:"enabled,omitempty"`
// ModelPath — an ECAPA-TDNN (or equivalent) speaker-embedding ONNX model.
// Required; without it the recognizer runs disabled and says so once.
ModelPath string `json:"model_path,omitempty"`
// LibPath — onnxruntime shared library, as for the text embedder. Empty ⇒
// the same default the embedder block uses.
LibPath string `json:"lib_path,omitempty"`
// Threshold — cosine similarity a match must beat. 0 ⇒
// speaker.DefaultThreshold (0.7). Lower it and she starts calling guests by
// his name, which is the expensive direction of this error.
Threshold float64 `json:"threshold,omitempty"`
// MinSeconds — least speech an identification will look at. 0 ⇒
// speaker.DefaultMinSeconds (2s).
MinSeconds float64 `json:"min_seconds,omitempty"`
}
// Recognizes reports whether voice identification should be wired. Safe on a
// nil receiver, and false without a model path — enabled with nothing to embed
// with is a misconfiguration, not a capability.
func (s *SpeakerConfig) Recognizes() bool {
return s != nil && s.Enabled && strings.TrimSpace(s.ModelPath) != ""
}
// WeatherConfig configures the weather provider for voice queries.
type WeatherConfig struct {
Provider string `json:"provider,omitempty"` // "open-meteo" or "" → stub
@@ -396,6 +739,109 @@ func (p *PatternProposalConfig) AnnounceProposals() bool {
return p != nil && p.Notify
}
// FeedsConfig — the RSS/Atom reader (Vikunja #258, docs/plans/13-rss-news-feeds.md).
//
// Absent ⇒ off, and off means no outbound request at all. Present with an empty
// `sources` list is also off — a poller with nothing to poll is not wired.
//
// What a feed may NOT do here: speak. Items are written as notes with source
// "rss:<name>" and read back when he asks; nothing is dispatched, nudged or
// announced on arrival. That is the "not a nag" constraint, and it is why there
// is no severity or channel field in this block to reach for.
type FeedsConfig struct {
// Sources — the feeds to read. Empty ⇒ the reader stays down.
Sources []FeedSourceConfig `json:"sources,omitempty"`
// PollInterval — default per-feed cadence. 0 ⇒ rss.DefaultPollInterval (30m).
PollInterval Duration `json:"poll_interval,omitempty"`
// MaxItems — most items kept from one feed in one poll. 0 ⇒
// rss.DefaultMaxItems (5). This is the "не завали мне /dash" knob.
MaxItems int `json:"max_items,omitempty"`
// MaxAge — on a first poll (no saved mark), how far back to take items.
// 0 ⇒ rss.DefaultMaxAge (24h), so switching a feed on imports today, not
// the archive.
MaxAge Duration `json:"max_age,omitempty"`
// AllowHosts — when set, the reader may only connect to these hosts (and
// their subdomains). The feed URLs' own hosts are added automatically, so
// this is only needed to be stricter than that.
AllowHosts []string `json:"allow_hosts,omitempty"`
// Timeout — per-request budget. 0 ⇒ webfetch.DefaultTimeout.
Timeout Duration `json:"timeout,omitempty"`
// MaxBytes — response size cap. 0 ⇒ webfetch.DefaultMaxBytes (2 MiB).
MaxBytes int64 `json:"max_bytes,omitempty"`
}
// FeedSourceConfig — one feed.
type FeedSourceConfig struct {
Name string `json:"name"` // note source is "rss:<name>"
URL string `json:"url"` // http(s) only
Category string `json:"category,omitempty"` // "технологии" — what "что нового по X?" matches
Interval Duration `json:"interval,omitempty"` // 0 ⇒ FeedsConfig.PollInterval
Include []string `json:"include,omitempty"` // keep only items containing one of these
Exclude []string `json:"exclude,omitempty"` // drop items containing any of these
}
// CrawlConfig — the web crawler (Vikunja #259, docs/plans/14-web-crawler.md).
//
// Absent ⇒ off, and off means no page is ever fetched. Present with neither
// `on_demand` nor a `watches` entry is also off: there would be nothing to do.
//
// The crawler is the LAST place an answer is looked for, behind the model, his
// own memory and the local Kiwix ZIMs. That ordering lives in the query-source
// chain (cmd/mavend/actions_query.go), not here, but it is the reason this block
// is small: it is a fallback, not a search engine.
//
// Only the URL leaves the box. His notes, facts, persona block and history are
// never part of a request — the crawler package cannot even read the store.
type CrawlConfig struct {
// OnDemand — may he ask her to read a page he names out loud
// ("посмотри https://… — что там пишут?"). false ⇒ the on-demand answer
// source stays off and only the watches below run.
OnDemand bool `json:"on_demand,omitempty"`
// Watches — pages re-read on a schedule. A page whose text changed is
// written as a note (source "crawl:<name>"); nothing is announced.
Watches []CrawlWatchConfig `json:"watches,omitempty"`
// Interval — default watch cadence. 0 ⇒ crawl.DefaultWatchInterval (6h).
Interval Duration `json:"interval,omitempty"`
// AllowHosts — when set, the ONLY hosts the crawler may reach (subdomains
// included). Watched pages' own hosts are added automatically. Setting this
// is how "she may read the arch wiki and nothing else" is expressed.
AllowHosts []string `json:"allow_hosts,omitempty"`
// DenyHosts — never reachable, checked first. Private addresses do not need
// to be listed: they are refused unconditionally (see internal/webfetch).
DenyHosts []string `json:"deny_hosts,omitempty"`
// UserAgent — sent on every request AND matched against robots.txt groups.
// Empty ⇒ webfetch.DefaultUserAgent.
UserAgent string `json:"user_agent,omitempty"`
// Timeout — per-request budget. 0 ⇒ webfetch.DefaultTimeout.
Timeout Duration `json:"timeout,omitempty"`
// MaxBytes — response size cap. 0 ⇒ webfetch.DefaultMaxBytes (2 MiB).
MaxBytes int64 `json:"max_bytes,omitempty"`
// MaxRunes — how much extracted text is kept. 0 ⇒ crawl.DefaultMaxRunes
// (4000), which is what fits a 4096-token context alongside a prompt.
MaxRunes int `json:"max_runes,omitempty"`
}
// CrawlWatchConfig — one page kept an eye on.
type CrawlWatchConfig struct {
Name string `json:"name"` // note source is "crawl:<name>"
URL string `json:"url"`
Interval Duration `json:"interval,omitempty"` // 0 ⇒ CrawlConfig.Interval
}
// MemoryEvalConfig — the background memory-evaluation loop (Vikunja #248).
// Absent ⇒ off, like every other capability that costs something the owner did
// not ask for. Each evaluation is a full LLM round-trip on the one resident
@@ -418,6 +864,26 @@ type MemoryEvalConfig struct {
MinConfidence float64 `json:"min_confidence,omitempty"`
}
// EmailConfig — core's half of the email reader: how many task candidates one
// message may produce, and how long the extraction call may take.
//
// There is deliberately nothing about a mailbox here. Core does not connect to
// IMAP, does not know an account exists, and holds no mail credential — the
// reader daemon does, the same split mavpoll uses for the zenmoney token. This
// block only says "extraction is allowed, with these bounds".
type EmailConfig struct {
// MaxTasks — candidates per message. 0 ⇒ email.MaxCandidates (3).
MaxTasks int `json:"max_tasks,omitempty"`
// Timeout — per-message extraction budget. 0 ⇒ DefaultEmailTimeout. This is
// a Thinking model reading a mail; nobody is waiting on the answer, but a
// hung llama-server must not pin the reader's connection forever.
Timeout Duration `json:"timeout,omitempty"`
}
// DefaultEmailTimeout — extraction budget per message.
const DefaultEmailTimeout = 2 * time.Minute
// PhraserConfig — the LLM-backed phraser seam. The daemon spawns llama-server
// as a managed subprocess and sends chat-completion requests to phrase nudge
// and reminder messages. nil ⇒ the template-based Stub is used instead.
@@ -441,6 +907,21 @@ type PhraserConfig struct {
// persona and invented units). Chat, query and reminder phrasing always go
// through the model regardless. See phraser.Config.LLMNudges.
LLMNudges bool `json:"llm_nudges,omitempty"`
// SwapModels — the gguf files the running daemon is allowed to swap to
// without a restart (Vikunja #250). Empty (the default) means the swap
// capability does not exist: ipc.MethodSwapModel answers ErrUnknownMethod,
// exactly like an unconfigured weather or telegram block.
//
// It is an allowlist and not a directory on purpose. The request carries a
// path, and llama-server is started with it as `-m`; anything short of an
// exact match against a list a human wrote in this file would make "swap the
// model" mean "load a file of your choosing off my disk". ModelPath is
// always swappable back to whether or not it is listed.
//
// Paths must be absolute — the daemon's working directory is not the
// operator's, and a relative path here would resolve somewhere surprising.
SwapModels []string `json:"swap_models,omitempty"`
}
// EmbedderConfig — paths for the ONNX multilingual embedder. The daemon
@@ -501,7 +982,11 @@ const (
DefaultRepeatInterval = 5 * time.Minute
DefaultAutotuneInterval = 10 * time.Minute
DefaultRouterThreshold = 0.55
DefaultQueryMinScore = 0.55
// DefaultIntakeJournal — entries kept in the unified intake journal
// (Vikunja #283). A busy day is a few hundred intake writes, so this is
// roughly "today and yesterday" at a few hundred KB of memory.
DefaultIntakeJournal = 512
DefaultQueryMinScore = 0.55
// Read off the margin sweep in internal/memory/recalleval on the e5
// embedder: 0.008 answers 68% of real questions (down from 72%) and cuts
// false recall from 5/5 to 1/5. Every larger delta costs real recall
@@ -551,6 +1036,9 @@ func Load(path string) (*Config, error) {
}
func (c *Config) applyDefaults() {
if c.IntakeJournal == 0 {
c.IntakeJournal = DefaultIntakeJournal
}
if c.TickInterval == 0 {
c.TickInterval = Duration(DefaultTickInterval)
}
@@ -607,6 +1095,30 @@ func (c *Config) applyDefaults() {
c.MemoryEval.Interval = Duration(DefaultMemoryEvalInterval)
}
// Same rule again: absent stays nil (⇒ mail ingestion refused), present gets
// the timeout default so `{}` is a valid "on with the defaults".
if c.Email != nil && c.Email.Timeout <= 0 {
c.Email.Timeout = Duration(DefaultEmailTimeout)
}
// A feeds block with no sources is the same as no block: nothing to poll,
// nothing wired. Normalising it to nil keeps that "off" in one place.
if c.Feeds != nil && len(c.Feeds.Sources) == 0 {
c.Feeds = nil
}
// Same rule for MCP: a block with no server, or none enabled, is the same
// as no block at all. Normalising it to nil keeps "off" in one place.
if c.MCP != nil && len(c.MCPServers()) == 0 {
c.MCP = nil
}
// Same rule for the crawler: a block that neither answers on demand nor
// watches anything has nothing to do, so it is normalised to "off".
if c.Crawl != nil && !c.Crawl.OnDemand && len(c.Crawl.Watches) == 0 {
c.Crawl = nil
}
if c.Voice != nil {
if c.Voice.RouterThreshold <= 0 {
c.Voice.RouterThreshold = DefaultRouterThreshold
@@ -664,6 +1176,22 @@ func (c *Config) validate() error {
if c.Phraser.ModelPath == "" {
return errors.New("phraser.model_path is required")
}
// A relative entry in the swap allowlist would resolve against the
// daemon's working directory, so the path a human reads in this file
// would not be the path llama-server is handed. Fail at startup.
for _, m := range c.Phraser.SwapModels {
if !filepath.IsAbs(m) {
return fmt.Errorf("phraser.swap_models: %q must be an absolute path", m)
}
}
}
// The update block is validated here even though mavend never acts on it: a
// half-written update config that is only noticed by cmd/mavupdate is noticed
// at the worst possible moment, halfway through deploying a new build.
if c.Update != nil {
if err := c.Update.Validate(); err != nil {
return err
}
}
if c.Voice != nil && c.Voice.Enabled {
if c.Voice.Bind == "" {
@@ -689,6 +1217,12 @@ func (c *Config) validate() error {
return fmt.Errorf("routine %q: bad cron %q: %w", r.Name, r.Cron, err)
}
}
// An MCP block with a typo (no name, both command and url, a bare hostname
// as the url) fails here, at startup, rather than at the first turn that
// needed the tool.
if err := mcp.Validate(c.MCPServers()); err != nil {
return err
}
if len(c.MorningRoutines) > 0 {
if err := morning.Validate(morningRoutinesFromConfig(c.MorningRoutines)); err != nil {
return err
+73
View File
@@ -293,3 +293,76 @@ func TestPatternProposalNotifyDefaultsOff(t *testing.T) {
t.Errorf("cooldown = %v, want 6h", c.PatternProposals.Cooldown)
}
}
// TestSwapModelsAbsentMeansOff — the swap capability does not exist unless the
// operator lists the models he allows (Vikunja #250).
func TestSwapModelsAbsentMeansOff(t *testing.T) {
c, err := Load(writeConfig(t, `{"phraser": {"model_path": "/m/qwen.gguf"}}`))
if err != nil {
t.Fatalf("Load: %v", err)
}
if len(c.Phraser.SwapModels) != 0 {
t.Errorf("swap_models = %v; want empty when unconfigured", c.Phraser.SwapModels)
}
}
func TestSwapModelsParsedAndMustBeAbsolute(t *testing.T) {
c, err := Load(writeConfig(t, `{"phraser": {
"model_path": "/m/qwen.gguf",
"swap_models": ["/m/qwen.gguf", "/m/qwen-cpt.gguf"]
}}`))
if err != nil {
t.Fatalf("Load: %v", err)
}
if len(c.Phraser.SwapModels) != 2 {
t.Fatalf("swap_models = %v; want 2 entries", c.Phraser.SwapModels)
}
// A relative entry would resolve against the daemon's cwd, not the operator's.
if _, err := Load(writeConfig(t, `{"phraser": {
"model_path": "/m/qwen.gguf",
"swap_models": ["models/llm/qwen.gguf"]
}}`)); err == nil {
t.Error("Load accepted a relative swap_models entry; want a startup failure")
}
}
// TestUpdateBlockAbsentMeansOff — mavend never updates itself; the block only
// exists so cmd/mavupdate can find the deployment it is asked to update
// (Vikunja #249). Absent is the normal state.
func TestUpdateBlockAbsentMeansOff(t *testing.T) {
c, err := Load(writeConfig(t, `{}`))
if err != nil {
t.Fatalf("Load: %v", err)
}
if c.Update != nil {
t.Errorf("update = %+v; want nil when unconfigured", c.Update)
}
}
func TestUpdateBlockValidatedAtStartup(t *testing.T) {
good := `{"update": {
"source_dir": "/srv/maven",
"install_dir": "/srv/maven",
"snapshot_dir": "/var/lib/maven/snapshots",
"binaries": ["mavend", "mavweb"],
"restart_cmd": ["docker", "compose", "up", "-d", "--build", "mavend"],
"health_socket": "/run/maven/mavend.sock"
}}`
c, err := Load(writeConfig(t, good))
if err != nil {
t.Fatalf("Load: %v", err)
}
if c.Update == nil || len(c.Update.Binaries) != 2 {
t.Fatalf("update block = %+v; want it parsed", c.Update)
}
// A block with no health check cannot detect its own failure, so it cannot
// roll back — refused at load, not halfway through a deploy.
noHealth := `{"update": {
"source_dir": "/srv/maven", "install_dir": "/srv/maven",
"snapshot_dir": "/var/lib/maven/snapshots",
"binaries": ["mavend"], "restart_cmd": ["true"]
}}`
if _, err := Load(writeConfig(t, noHealth)); err == nil {
t.Error("Load accepted an update block with no health_socket")
}
}
+82
View File
@@ -0,0 +1,82 @@
package config
import (
"testing"
"time"
)
func TestMCPAbsentIsOff(t *testing.T) {
c, err := Load(writeConfig(t, `{}`))
if err != nil {
t.Fatal(err)
}
if c.MCP != nil {
t.Error("no mcp block ⇒ nil")
}
if got := c.MCPServers(); got != nil {
t.Errorf("MCPServers() = %+v, want nil", got)
}
}
// A described-but-not-enabled server must not be wired. This is how a block can
// sit in the config file, reviewed, before it is switched on.
func TestMCPDisabledServerIsOff(t *testing.T) {
c, err := Load(writeConfig(t, `{"mcp":{"servers":[
{"name":"vikunja","url":"http://localhost:9100/mcp","allow_private":true}]}}`))
if err != nil {
t.Fatal(err)
}
if c.MCP != nil {
t.Errorf("a block with nothing enabled must normalise to nil, got %+v", c.MCP)
}
if got := c.MCPServers(); len(got) != 0 {
t.Errorf("MCPServers() = %+v", got)
}
}
func TestMCPEnabledServerMapping(t *testing.T) {
c, err := Load(writeConfig(t, `{"mcp":{
"timeout":"5s",
"servers":[
{"name":"vikunja","url":"http://localhost:9100/mcp","allow_private":true,
"allow_tools":["list_tasks"],"max_tools":3,"enabled":true},
{"name":"files","command":"mcp-server-fs","args":["/srv"],"timeout":"1s","enabled":true},
{"name":"off","command":"nope"}
]}}`))
if err != nil {
t.Fatal(err)
}
got := c.MCPServers()
if len(got) != 2 {
t.Fatalf("servers = %+v", got)
}
if got[0].Name != "vikunja" || !got[0].AllowPrivate || got[0].MaxTools != 3 ||
len(got[0].AllowTools) != 1 || got[0].Timeout != 5*time.Second {
t.Errorf("vikunja mapped wrong: %+v", got[0])
}
if got[1].Command != "mcp-server-fs" || len(got[1].Args) != 1 || got[1].Timeout != time.Second {
t.Errorf("files mapped wrong: %+v", got[1])
}
// allow_private is per server and must not leak to the other one.
if got[1].AllowPrivate {
t.Error("allow_private leaked between servers")
}
}
func TestMCPBadServerFailsAtStartup(t *testing.T) {
cases := map[string]string{
"no name": `{"mcp":{"servers":[{"command":"x","enabled":true}]}}`,
"both": `{"mcp":{"servers":[{"name":"a","command":"x","url":"http://a.test","enabled":true}]}}`,
"neither": `{"mcp":{"servers":[{"name":"a","enabled":true}]}}`,
"bad scheme": `{"mcp":{"servers":[{"name":"a","url":"unix:///run/x.sock","enabled":true}]}}`,
"duplicate": `{"mcp":{"servers":[{"name":"a","command":"x","enabled":true},{"name":"a","command":"y","enabled":true}]}}`,
"spacey name": `{"mcp":{"servers":[{"name":"a b","command":"x","enabled":true}]}}`,
}
for name, body := range cases {
t.Run(name, func(t *testing.T) {
if _, err := Load(writeConfig(t, body)); err == nil {
t.Fatal("want a startup error")
}
})
}
}
+223
View File
@@ -0,0 +1,223 @@
package config
import (
"encoding/json"
"testing"
"time"
)
// Absent blocks must read as off on a nil receiver: the daemon calls these
// helpers before it knows whether the operator configured anything.
func TestSensesOffByDefault(t *testing.T) {
var cfg Config
if cfg.Media.StoreDir() != "" {
t.Error("media store dir is set with no media block")
}
if cfg.Vision.LooksAtImages() {
t.Error("vision is on with no vision block")
}
if cfg.Capture.Records() {
t.Error("the recorder is on with no capture block")
}
if cfg.Capture.MaxDuration() != 0 {
t.Error("a nil capture block invented a duration")
}
if cfg.Speaker.Recognizes() {
t.Error("speaker recognition is on with no speaker block")
}
}
// The recorder is the capability that most needs its default to be off, so it
// gets its own test rather than a line in the one above.
func TestCaptureIsOffUntilExplicitlyEnabled(t *testing.T) {
cases := []struct {
name string
c *CaptureConfig
want bool
}{
{"absent", nil, false},
{"present but not enabled", &CaptureConfig{MaxMinutes: 60}, false},
{"enabled", &CaptureConfig{Enabled: true}, true},
}
for _, c := range cases {
if got := c.c.Records(); got != c.want {
t.Errorf("%s: Records() = %v, want %v", c.name, got, c.want)
}
}
}
func TestCaptureBlockParsesFromJSON(t *testing.T) {
raw := `{"capture":{"enabled":true,"max_minutes":45,"stt_window":"2m",
"chunk_runes":2000,"max_chunks":10,"save_transcript":true}}`
var cfg Config
if err := json.Unmarshal([]byte(raw), &cfg); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if !cfg.Capture.Records() {
t.Fatal("capture did not parse as enabled")
}
if cfg.Capture.MaxDuration() != 45*time.Minute {
t.Errorf("max duration = %v", cfg.Capture.MaxDuration())
}
if time.Duration(cfg.Capture.STTWindow) != 2*time.Minute {
t.Errorf("stt window = %v", time.Duration(cfg.Capture.STTWindow))
}
if cfg.Capture.ChunkRunes != 2000 || cfg.Capture.MaxChunks != 10 {
t.Errorf("summariser limits = %+v", cfg.Capture)
}
if !cfg.Capture.SaveTranscript {
t.Error("save_transcript did not parse")
}
}
// Keeping the verbatim record of what other people said is the heavier act, so
// it is separately opt-in from recording at all.
func TestTranscriptIsNotSavedByDefault(t *testing.T) {
var cfg Config
if err := json.Unmarshal([]byte(`{"capture":{"enabled":true}}`), &cfg); err != nil {
t.Fatal(err)
}
if cfg.Capture.SaveTranscript {
t.Error("transcripts are saved without anyone asking")
}
if cfg.Capture.MaxDuration() != 0 {
t.Error("max_minutes defaulted in config instead of in the package")
}
}
// enabled with nothing to talk to is a misconfiguration, not a capability.
func TestVisionNeedsBothEnabledAndEndpoint(t *testing.T) {
cases := []struct {
name string
v *VisionConfig
want bool
}{
{"absent", nil, false},
{"endpoint but not enabled", &VisionConfig{Endpoint: "http://127.0.0.1:8081"}, false},
{"enabled but no endpoint", &VisionConfig{Enabled: true}, false},
{"enabled, blank endpoint", &VisionConfig{Enabled: true, Endpoint: " "}, false},
{"both", &VisionConfig{Enabled: true, Endpoint: "http://127.0.0.1:8081"}, true},
}
for _, c := range cases {
if got := c.v.LooksAtImages(); got != c.want {
t.Errorf("%s: LooksAtImages() = %v, want %v", c.name, got, c.want)
}
}
}
func TestSensesBlocksParseFromJSON(t *testing.T) {
raw := `{
"db_path": "/tmp/x.db",
"socket_path": "/tmp/x.sock",
"media": {"dir": "media", "retention": "48h", "max_bytes": 1048576},
"vision": {
"enabled": true,
"endpoint": "http://127.0.0.1:8081",
"model": "qwen2.5-vl",
"max_dim": 640,
"max_tokens": 200,
"timeout": "45s",
"prompt": "Что тут?"
}
}`
var cfg Config
if err := json.Unmarshal([]byte(raw), &cfg); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if cfg.Media.StoreDir() != "media" {
t.Errorf("media dir = %q", cfg.Media.StoreDir())
}
if time.Duration(cfg.Media.Retention) != 48*time.Hour {
t.Errorf("retention = %v", time.Duration(cfg.Media.Retention))
}
if cfg.Media.MaxBytes != 1<<20 {
t.Errorf("max_bytes = %d", cfg.Media.MaxBytes)
}
if !cfg.Vision.LooksAtImages() {
t.Fatal("vision did not parse as enabled")
}
if cfg.Vision.MaxDim != 640 || cfg.Vision.MaxTokens != 200 {
t.Errorf("vision limits = %+v", cfg.Vision)
}
if time.Duration(cfg.Vision.Timeout) != 45*time.Second {
t.Errorf("vision timeout = %v", time.Duration(cfg.Vision.Timeout))
}
if cfg.Vision.Prompt != "Что тут?" {
t.Errorf("prompt = %q", cfg.Vision.Prompt)
}
}
// A media dir set with no vision block is a valid state, and the useful one on a
// box with no vision model: images can be kept, they just cannot be described.
func TestMediaWithoutVisionIsValid(t *testing.T) {
var cfg Config
if err := json.Unmarshal([]byte(`{"media":{"dir":"/srv/media"}}`), &cfg); err != nil {
t.Fatal(err)
}
if cfg.Media.StoreDir() != "/srv/media" {
t.Errorf("dir = %q", cfg.Media.StoreDir())
}
if cfg.Vision.LooksAtImages() {
t.Error("vision came on by itself")
}
}
// A voiceprint is a biometric of a named person. Nothing about it turns on by
// itself: no speaker block means no recognition, and no enrolment either.
func TestSpeakerIsOffUntilExplicitlyEnabled(t *testing.T) {
var cfg Config
if err := json.Unmarshal([]byte(`{}`), &cfg); err != nil {
t.Fatal(err)
}
if cfg.Speaker.Recognizes() {
t.Error("speaker recognition came on with no config at all")
}
var empty Config
if err := json.Unmarshal([]byte(`{"speaker":{}}`), &empty); err != nil {
t.Fatal(err)
}
if empty.Speaker.Recognizes() {
t.Error("an empty speaker block enabled recognition")
}
}
// Enabled alone is not enough: recognition needs a model, and on this box there
// is none. Recognizes() must stay false so the daemon reports the honest state
// instead of claiming a capability it cannot perform.
func TestSpeakerNeedsBothEnabledAndAModel(t *testing.T) {
var cfg Config
if err := json.Unmarshal([]byte(`{"speaker":{"enabled":true}}`), &cfg); err != nil {
t.Fatal(err)
}
if cfg.Speaker.Recognizes() {
t.Error("enabled with no model_path claimed to recognise")
}
var only Config
if err := json.Unmarshal([]byte(`{"speaker":{"model_path":"/opt/x.onnx"}}`), &only); err != nil {
t.Fatal(err)
}
if only.Speaker.Recognizes() {
t.Error("a model_path alone enabled recognition")
}
}
func TestSpeakerBlockParsesFromJSON(t *testing.T) {
const raw = `{"speaker":{"enabled":true,"model_path":"/opt/maven/models/spk/ecapa.onnx",` +
`"lib_path":"/opt/maven/lib","threshold":0.62,"min_seconds":1.5}}`
var cfg Config
if err := json.Unmarshal([]byte(raw), &cfg); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if !cfg.Speaker.Recognizes() {
t.Fatal("speaker did not parse as enabled")
}
if cfg.Speaker.ModelPath != "/opt/maven/models/spk/ecapa.onnx" {
t.Errorf("model_path = %q", cfg.Speaker.ModelPath)
}
if cfg.Speaker.LibPath != "/opt/maven/lib" {
t.Errorf("lib_path = %q", cfg.Speaker.LibPath)
}
if cfg.Speaker.Threshold != 0.62 || cfg.Speaker.MinSeconds != 1.5 {
t.Errorf("thresholds = %+v", cfg.Speaker)
}
}
+174
View File
@@ -0,0 +1,174 @@
// Package crawl reads a web page: fetch, robots check, HTML to text.
//
// It is the LAST place Maven looks for an answer, and that ordering is the whole
// design. "Never phones home" is deprecated, but what replaced it puts local
// sources first: the resident model, then his own memory, then the Kiwix ZIMs on
// the box (internal/kiwix), and only then the network. A local read costs
// nothing and leaks nothing; a fetch costs a round-trip and puts a URL in
// someone's access log. So this package exists to be the fallback, not the
// front door — see the querySources chain in cmd/mavend/actions_query.go for
// where it actually sits.
//
// What never leaves the box: his notes, his facts, the persona block, the
// conversation history. Only the URL is requested and, for the on-demand path,
// only because he said it out loud. Nothing here reads the store.
//
// The limits are not in this package — they are in internal/webfetch, which is
// the only way anything here touches a socket: http(s) only, host allow/deny,
// private-address refusal, size cap, redirect cap, per-host rate limit. What
// this package adds is politeness (robots.txt) and dedup.
package crawl
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"net/url"
"strings"
"time"
)
// Errors callers distinguish.
var (
ErrRobots = errors.New("crawl: robots.txt disallows this path")
ErrNotHTML = errors.New("crawl: response is not html or text")
)
// Fetcher is the guarded HTTP door (internal/webfetch adapted by the daemon). An
// interface so this package constructs no http.Client of its own and can be
// tested without a network.
type Fetcher interface {
Get(ctx context.Context, url string) (*Response, error)
}
// Response is the minimum a crawl needs from a fetch.
type Response struct {
URL string
ContentType string
Body []byte
}
// Config — crawler knobs.
type Config struct {
// UserAgent is the name matched against robots.txt groups. It must be the
// same string the fetcher sends, or Maven would be claiming one identity
// and obeying the rules for another.
UserAgent string
// MaxRunes caps extracted text. 0 ⇒ DefaultMaxRunes.
MaxRunes int
// RobotsTTL — how long a parsed robots.txt is trusted. 0 ⇒ 1h.
RobotsTTL time.Duration
// Now is injectable for tests. nil ⇒ time.Now.
Now func() time.Time
}
// Crawler fetches and extracts pages. Safe for concurrent use.
type Crawler struct {
fetch Fetcher
cfg Config
robots *robotsCache
}
// New builds a crawler. Returns nil when there is no fetcher, which is how the
// daemon expresses "crawling is off unless configured".
func New(fetch Fetcher, cfg Config) *Crawler {
if fetch == nil {
return nil
}
if cfg.UserAgent == "" {
cfg.UserAgent = "Maven"
}
if cfg.MaxRunes <= 0 {
cfg.MaxRunes = DefaultMaxRunes
}
if cfg.RobotsTTL <= 0 {
cfg.RobotsTTL = time.Hour
}
if cfg.Now == nil {
cfg.Now = time.Now
}
return &Crawler{fetch: fetch, cfg: cfg, robots: newRobotsCache(cfg.RobotsTTL)}
}
// Page fetches rawURL and returns its text. It checks robots.txt first and
// refuses a disallowed path with ErrRobots — there is no override.
func (c *Crawler) Page(ctx context.Context, rawURL string) (Page, error) {
u, err := url.Parse(strings.TrimSpace(rawURL))
if err != nil {
return Page{}, fmt.Errorf("crawl: bad url %q: %w", rawURL, err)
}
ok, err := c.allowed(ctx, u)
if err != nil {
return Page{}, err
}
if !ok {
return Page{}, fmt.Errorf("%w: %s", ErrRobots, u.Path)
}
resp, err := c.fetch.Get(ctx, u.String())
if err != nil {
return Page{}, err
}
// A PDF or an image is bytes Maven cannot read; saying so beats storing
// binary garbage as a "note".
ct := strings.ToLower(resp.ContentType)
if ct != "" && !strings.Contains(ct, "html") && !strings.Contains(ct, "text/") &&
!strings.Contains(ct, "xml") && !strings.Contains(ct, "json") {
return Page{}, fmt.Errorf("%w: %s", ErrNotHTML, resp.ContentType)
}
return Extract(resp.URL, resp.Body, c.cfg.MaxRunes), nil
}
// allowed consults robots.txt for u's host, reading it at most once per TTL.
//
// A robots.txt that cannot be fetched (404, a timeout, a blocked host) means
// allow, per the standard. The one thing that is NOT fail-open is an explicit
// Disallow.
func (c *Crawler) allowed(ctx context.Context, u *url.URL) (bool, error) {
host := u.Host
now := c.cfg.Now()
rules, ok := c.robots.get(host, now)
if !ok {
robotsURL := u.Scheme + "://" + host + "/robots.txt"
resp, err := c.fetch.Get(ctx, robotsURL)
switch {
case err != nil:
// Note what is NOT swallowed: a refusal from the guarded fetcher.
// If webfetch says this host is denied or private, the page fetch
// would fail the same way, and reporting the real reason beats
// reporting a robots verdict we never got.
if isFatalFetchError(err) {
return false, err
}
rules = Rules{}
default:
rules = ParseRobots(string(resp.Body), c.cfg.UserAgent)
}
c.robots.put(host, rules, now)
}
path := u.EscapedPath()
if u.RawQuery != "" {
path += "?" + u.RawQuery
}
return rules.Allowed(path), nil
}
// isFatalFetchError — a fetch failure that means "this host is off limits"
// rather than "there is no robots.txt here". The sentinel set is webfetch's, but
// this package must not import it (the interface exists precisely so it does
// not), so the check is on the message. Ugly and honest: the alternative is a
// dependency inversion for two strings.
func isFatalFetchError(err error) bool {
s := err.Error()
return strings.Contains(s, "not allowed") || strings.Contains(s, "private address") ||
strings.Contains(s, "only http and https")
}
// Hash is the dedup key for a crawl result: the sha256 of the extracted text,
// hex, first 16 chars. Text and not raw HTML, because a page whose only change
// is a rotating ad slot or a CSRF token has not changed.
func Hash(text string) string {
sum := sha256.Sum256([]byte(strings.TrimSpace(text)))
return hex.EncodeToString(sum[:])[:16]
}
+155
View File
@@ -0,0 +1,155 @@
package crawl
import (
"context"
"errors"
"strings"
"testing"
"time"
)
// fakeFetcher serves canned pages by URL and counts requests, so a test can
// assert that robots.txt was read once and that a refusal never reached the page.
type fakeFetcher struct {
pages map[string]Response
err error
calls []string
}
func (f *fakeFetcher) Get(_ context.Context, u string) (*Response, error) {
f.calls = append(f.calls, u)
if f.err != nil {
return nil, f.err
}
r, ok := f.pages[u]
if !ok {
return nil, errors.New("http 404")
}
if r.URL == "" {
r.URL = u
}
if r.ContentType == "" {
r.ContentType = "text/html; charset=utf-8"
}
return &r, nil
}
const htmlPage = `<html><head><title>Почему небо синее</title>
<style>body{color:red}</style><script>track()</script></head>
<body><nav>меню</nav><h1>Небо</h1>
<p>Свет рассеивается на молекулах воздуха.</p>
<p>Короткие волны рассеиваются сильнее.</p>
<footer>© 2026</footer></body></html>`
func newTestCrawler(f *fakeFetcher) *Crawler {
return New(f, Config{UserAgent: "Maven/1.0", Now: func() time.Time { return time.Unix(0, 0) }})
}
func TestPageExtractsText(t *testing.T) {
f := &fakeFetcher{pages: map[string]Response{
"https://example.org/sky": {Body: []byte(htmlPage)},
}}
page, err := newTestCrawler(f).Page(context.Background(), "https://example.org/sky")
if err != nil {
t.Fatal(err)
}
if page.Title != "Почему небо синее" {
t.Errorf("title = %q", page.Title)
}
if !strings.Contains(page.Text, "Свет рассеивается") {
t.Errorf("body text missing: %q", page.Text)
}
for _, junk := range []string{"track()", "color:red", "меню", "© 2026"} {
if strings.Contains(page.Text, junk) {
t.Errorf("%q survived extraction: %q", junk, page.Text)
}
}
}
func TestRobotsIsCheckedAndObeyed(t *testing.T) {
f := &fakeFetcher{pages: map[string]Response{
"https://example.org/robots.txt": {Body: []byte("User-agent: *\nDisallow: /secret\n"), ContentType: "text/plain"},
"https://example.org/secret/x": {Body: []byte(htmlPage)},
"https://example.org/open": {Body: []byte(htmlPage)},
}}
c := newTestCrawler(f)
if _, err := c.Page(context.Background(), "https://example.org/secret/x"); !errors.Is(err, ErrRobots) {
t.Fatalf("error = %v, want ErrRobots", err)
}
for _, u := range f.calls {
if strings.Contains(u, "/secret") {
t.Fatal("the disallowed page was fetched anyway")
}
}
if _, err := c.Page(context.Background(), "https://example.org/open"); err != nil {
t.Fatalf("allowed page: %v", err)
}
// robots.txt was read once for the host, not once per page.
robotsReads := 0
for _, u := range f.calls {
if strings.HasSuffix(u, "/robots.txt") {
robotsReads++
}
}
if robotsReads != 1 {
t.Fatalf("robots.txt read %d times, want 1", robotsReads)
}
}
// No robots.txt means allow — that is the standard, and the alternative makes
// most of the web unreadable.
func TestMissingRobotsAllows(t *testing.T) {
f := &fakeFetcher{pages: map[string]Response{
"https://example.org/page": {Body: []byte(htmlPage)},
}}
if _, err := newTestCrawler(f).Page(context.Background(), "https://example.org/page"); err != nil {
t.Fatalf("err = %v, want the page", err)
}
}
// A refusal from the guarded fetcher must surface as itself, not be laundered
// into "no robots.txt, go ahead".
func TestFetcherRefusalIsNotSwallowed(t *testing.T) {
f := &fakeFetcher{err: errors.New("webfetch: refusing to connect to a private address: 127.0.0.1")}
_, err := newTestCrawler(f).Page(context.Background(), "http://127.0.0.1:9100/mcp")
if err == nil || !strings.Contains(err.Error(), "private address") {
t.Fatalf("error = %v, want the fetcher's refusal", err)
}
}
func TestNonTextIsRefused(t *testing.T) {
f := &fakeFetcher{pages: map[string]Response{
"https://example.org/f.pdf": {Body: []byte("%PDF-1.7"), ContentType: "application/pdf"},
}}
if _, err := newTestCrawler(f).Page(context.Background(), "https://example.org/f.pdf"); !errors.Is(err, ErrNotHTML) {
t.Fatalf("error = %v, want ErrNotHTML", err)
}
}
func TestMaxRunesCapsText(t *testing.T) {
long := "<html><body><p>" + strings.Repeat("привет ", 2000) + "</p></body></html>"
f := &fakeFetcher{pages: map[string]Response{"https://example.org/l": {Body: []byte(long)}}}
c := New(f, Config{MaxRunes: 50})
page, err := c.Page(context.Background(), "https://example.org/l")
if err != nil {
t.Fatal(err)
}
if n := len([]rune(page.Text)); n > 51 {
t.Fatalf("text = %d runes, want the 50-rune cap", n)
}
}
func TestNewWithoutFetcherIsNil(t *testing.T) {
if New(nil, Config{}) != nil {
t.Fatal("a crawler with no fetcher must be nil — crawling is off unless configured")
}
}
func TestHashIgnoresNothingButText(t *testing.T) {
if Hash("a") == Hash("b") {
t.Fatal("different text hashed the same")
}
if Hash(" same \n") != Hash("same") {
t.Fatal("surrounding whitespace changed the hash")
}
}
+106
View File
@@ -0,0 +1,106 @@
package crawl
import (
"html"
"regexp"
"strings"
)
// HTML → text, with a regexp and no tokenizer.
//
// golang.org/x/net/html is not vendored and the network is not assumed, so this
// is stdlib. That is less of a compromise than it sounds: the unit of context
// here is a few hundred words for a 4096-token model to read, exactly like the
// Kiwix snippet, so what matters is dropping script/style/nav noise and keeping
// paragraph boundaries. A DOM would buy correctness on malformed markup that is
// then thrown away by truncation anyway.
//
// What this deliberately does NOT do: run JavaScript, follow links, or extract
// structured fields with CSS selectors or an LLM prompt. The plan's step 2 asked
// for the last of those; see docs/plans/14-web-crawler.md for why it was left
// out for now.
var (
// RE2 has no backreferences, so each tag pair is spelled out rather than
// captured and matched against itself.
dropRE = regexp.MustCompile(pairsRE("script", "style", "noscript", "svg", "head", "nav", "footer", "form"))
titleRE = regexp.MustCompile(`(?is)<title\b[^>]*>(.*?)</title>`)
h1RE = regexp.MustCompile(`(?is)<h1\b[^>]*>(.*?)</h1>`)
// Block-level tags become newlines so paragraphs survive as paragraphs.
blockRE = regexp.MustCompile(`(?is)</?(p|div|br|li|tr|h[1-6]|section|article|blockquote|pre)\b[^>]*>`)
tagRE = regexp.MustCompile(`(?s)<[^>]*>`)
commentRE = regexp.MustCompile(`(?s)<!--.*?-->`)
spaceRE = regexp.MustCompile(`[ \t\f\v]+`)
blankRE = regexp.MustCompile(`\n{2,}`)
)
// pairsRE builds `(?is)<tag …>…</tag>|…` for the given tags.
func pairsRE(tags ...string) string {
parts := make([]string, 0, len(tags))
for _, t := range tags {
parts = append(parts, `<`+t+`\b[^>]*>.*?</`+t+`>`)
}
return `(?is)` + strings.Join(parts, "|")
}
// Page is an extracted page.
type Page struct {
URL string
Title string
Text string // plain text, paragraphs separated by single newlines
}
// Extract turns a fetched HTML document into a Page. maxRunes caps the text (0 ⇒
// DefaultMaxRunes); the cap is on runes, not bytes, because a Russian page cut
// at a byte boundary ends in half a letter.
func Extract(url string, body []byte, maxRunes int) Page {
if maxRunes <= 0 {
maxRunes = DefaultMaxRunes
}
s := string(body)
s = commentRE.ReplaceAllString(s, " ")
title := firstGroup(titleRE, s)
if title == "" {
title = firstGroup(h1RE, s)
}
s = dropRE.ReplaceAllString(s, "\n")
s = blockRE.ReplaceAllString(s, "\n")
s = tagRE.ReplaceAllString(s, " ")
s = html.UnescapeString(s)
s = spaceRE.ReplaceAllString(s, " ")
var lines []string
for _, l := range strings.Split(s, "\n") {
if l = strings.TrimSpace(l); l != "" {
lines = append(lines, l)
}
}
text := blankRE.ReplaceAllString(strings.Join(lines, "\n"), "\n")
return Page{URL: url, Title: title, Text: TrimRunes(text, maxRunes)}
}
// DefaultMaxRunes — how much of a page is kept. ~4000 runes is a long answer's
// worth of context and still leaves room in a 4096-token window for the prompt
// and the reply.
const DefaultMaxRunes = 4000
func firstGroup(re *regexp.Regexp, s string) string {
m := re.FindStringSubmatch(s)
if len(m) < 2 {
return ""
}
t := tagRE.ReplaceAllString(m[1], " ")
return strings.TrimSpace(strings.Join(strings.Fields(html.UnescapeString(t)), " "))
}
// TrimRunes cuts s to at most max runes, on a rune boundary.
func TrimRunes(s string, max int) string {
r := []rune(s)
if len(r) <= max {
return s
}
return strings.TrimSpace(string(r[:max])) + "…"
}
+211
View File
@@ -0,0 +1,211 @@
package crawl
import (
"regexp"
"strings"
"sync"
"time"
)
// robots.txt, parsed the small way: no wildcards beyond the two the standard
// actually defines (`*` inside a path and `$` at the end), no sitemaps, no
// crawl-delay-per-agent gymnastics. A personal assistant reading a handful of
// pages does not need a spec-complete implementation; it needs to not be rude,
// and to be auditable in one sitting.
//
// Two rules worth stating because they are choices, not accidents:
//
// - a missing or unreadable robots.txt means ALLOW. That is what the standard
// says (404 ⇒ unrestricted), and the alternative would make a site that
// simply has no robots.txt unreadable;
// - an explicit Disallow means REFUSE, and Maven does not offer an override.
// There is no "but he asked me to" flag: the page is not read.
// Rules is a parsed robots.txt for one user-agent.
type Rules struct {
allow []string
disallow []string
// Delay is Crawl-delay in seconds when the group named one, 0 otherwise.
// The fetcher's own per-host rate limit is the floor; this can only make
// Maven slower, never faster.
Delay time.Duration
}
// ParseRobots reads robots.txt and returns the rules that apply to agent.
//
// Group selection follows the standard: the most specific matching group wins,
// which here means an exact user-agent match beats `*`. Lines that are neither
// are ignored rather than guessed at.
func ParseRobots(body string, agent string) Rules {
agent = strings.ToLower(agent)
type group struct {
agents []string
allow []string
disallow []string
delay time.Duration
}
var groups []group
var cur *group
// startNew tracks whether the next User-agent line opens a new group or
// joins the current one: consecutive User-agent lines share their rules.
startNew := true
for _, raw := range strings.Split(body, "\n") {
line := raw
if i := strings.IndexByte(line, '#'); i >= 0 {
line = line[:i]
}
line = strings.TrimSpace(line)
if line == "" {
continue
}
key, val, ok := strings.Cut(line, ":")
if !ok {
continue
}
key = strings.ToLower(strings.TrimSpace(key))
val = strings.TrimSpace(val)
switch key {
case "user-agent":
if startNew || cur == nil {
groups = append(groups, group{})
cur = &groups[len(groups)-1]
startNew = false
}
cur.agents = append(cur.agents, strings.ToLower(val))
case "disallow":
if cur == nil {
continue
}
startNew = true
// "Disallow:" with an empty value allows everything, and is not a
// path rule at all.
if val != "" {
cur.disallow = append(cur.disallow, val)
}
case "allow":
if cur == nil {
continue
}
startNew = true
if val != "" {
cur.allow = append(cur.allow, val)
}
case "crawl-delay":
if cur == nil {
continue
}
startNew = true
if d, err := time.ParseDuration(val + "s"); err == nil && d > 0 {
cur.delay = d
}
}
}
var star, exact *group
for i := range groups {
for _, a := range groups[i].agents {
if a == "*" && star == nil {
star = &groups[i]
}
// A robots.txt names "maven", we send "Maven/1.0 (…)": match on
// prefix, which is how every crawler reads this field.
if a != "*" && a != "" && strings.HasPrefix(agent, a) {
exact = &groups[i]
}
}
}
g := exact
if g == nil {
g = star
}
if g == nil {
return Rules{}
}
return Rules{allow: g.allow, disallow: g.disallow, Delay: g.delay}
}
// Allowed reports whether path may be fetched. Longest matching rule wins, and
// Allow beats Disallow at equal length — the standard's tie-break, and the one
// that makes "Disallow: /" plus "Allow: /public" mean what it looks like.
func (r Rules) Allowed(path string) bool {
if path == "" {
path = "/"
}
best, allowed := -1, true
for _, p := range r.disallow {
if n, ok := matchPath(p, path); ok && n > best {
best, allowed = n, false
}
}
for _, p := range r.allow {
if n, ok := matchPath(p, path); ok && n >= best {
best, allowed = n, true
}
}
return allowed
}
// matchPath applies a robots path pattern and returns the pattern's length as
// the specificity score. `*` matches any run of characters, `$` anchors the end.
// A pattern is a PREFIX match otherwise, which is what "Disallow: /admin" means.
func matchPath(pattern, path string) (int, bool) {
score := len(pattern)
re, err := robotsRegexp(pattern)
if err != nil {
return 0, false
}
return score, re.MatchString(path)
}
// robotsRegexp turns a robots path pattern into an anchored-at-the-start
// regexp. Everything but `*` and a trailing `$` is a literal, so the pattern is
// quoted first and the two metacharacters are put back afterwards.
func robotsRegexp(pattern string) (*regexp.Regexp, error) {
end := ""
if strings.HasSuffix(pattern, "$") {
pattern = strings.TrimSuffix(pattern, "$")
end = "$"
}
parts := strings.Split(pattern, "*")
for i, p := range parts {
parts[i] = regexp.QuoteMeta(p)
}
return regexp.Compile("^" + strings.Join(parts, ".*") + end)
}
// robotsCache holds parsed rules per host so a crawl of ten pages on one site
// reads robots.txt once. TTL because a site may change its mind, and a daemon
// that runs for weeks would otherwise never notice.
type robotsCache struct {
ttl time.Duration
mu sync.Mutex
m map[string]robotsEntry
}
type robotsEntry struct {
rules Rules
at time.Time
}
func newRobotsCache(ttl time.Duration) *robotsCache {
return &robotsCache{ttl: ttl, m: map[string]robotsEntry{}}
}
func (c *robotsCache) get(host string, now time.Time) (Rules, bool) {
c.mu.Lock()
defer c.mu.Unlock()
e, ok := c.m[host]
if !ok || now.Sub(e.at) > c.ttl {
return Rules{}, false
}
return e.rules, true
}
func (c *robotsCache) put(host string, r Rules, now time.Time) {
c.mu.Lock()
defer c.mu.Unlock()
c.m[host] = robotsEntry{rules: r, at: now}
}
+84
View File
@@ -0,0 +1,84 @@
package crawl
import (
"testing"
"time"
)
const robotsBody = `# a comment
User-agent: *
Disallow: /private
Disallow: /tmp/
Crawl-delay: 5
User-agent: Maven
Disallow: /
Allow: /public
`
func TestParseRobotsPicksTheMostSpecificGroup(t *testing.T) {
// The Maven group applies to us even though we send a longer UA string.
r := ParseRobots(robotsBody, "Maven/1.0 (self-hosted personal assistant)")
if r.Allowed("/anything") {
t.Error("Disallow: / in our own group was ignored")
}
if !r.Allowed("/public/page") {
t.Error("Allow: /public must beat the shorter Disallow: /")
}
// A different agent falls into the * group.
star := ParseRobots(robotsBody, "SomeoneElse/2")
if !star.Allowed("/anything") {
t.Error("the * group disallows nothing but /private and /tmp/")
}
if star.Allowed("/private/x") || star.Allowed("/tmp/") {
t.Error("the * group's disallows were not applied")
}
if star.Delay != 5*time.Second {
t.Errorf("crawl-delay = %v, want 5s", star.Delay)
}
}
func TestParseRobotsEmptyMeansAllowAll(t *testing.T) {
for _, body := range []string{"", "# nothing here\n", "User-agent: *\nDisallow:\n"} {
if !ParseRobots(body, "Maven").Allowed("/whatever") {
t.Errorf("body %q must allow everything", body)
}
}
}
func TestRobotsWildcards(t *testing.T) {
r := ParseRobots("User-agent: *\nDisallow: /*.pdf$\nDisallow: /a/*/secret\n", "Maven")
if r.Allowed("/docs/manual.pdf") {
t.Error("*.pdf$ did not match")
}
if !r.Allowed("/docs/manual.pdf.html") {
t.Error("$ must anchor at the end")
}
if r.Allowed("/a/b/secret") {
t.Error("/a/*/secret did not match")
}
if !r.Allowed("/a/b/public") {
t.Error("unrelated path was refused")
}
}
// Consecutive User-agent lines share one group, which is common in the wild.
func TestRobotsSharedGroup(t *testing.T) {
r := ParseRobots("User-agent: Googlebot\nUser-agent: Maven\nDisallow: /x\n", "Maven/1.0")
if r.Allowed("/x/y") {
t.Fatal("a shared group's rules were not applied to the second agent")
}
}
func TestRobotsCacheTTL(t *testing.T) {
c := newRobotsCache(time.Minute)
now := time.Now()
c.put("example.com", ParseRobots("User-agent: *\nDisallow: /\n", "Maven"), now)
if _, ok := c.get("example.com", now.Add(30*time.Second)); !ok {
t.Error("a fresh entry must be served from cache")
}
if _, ok := c.get("example.com", now.Add(2*time.Minute)); ok {
t.Error("an expired entry must be re-read")
}
}
+177
View File
@@ -0,0 +1,177 @@
package crawl
import (
"context"
"fmt"
"log"
"strings"
"time"
)
// Scheduled crawls: a page is re-read on an interval, and when its TEXT changed
// the new text is written as a note. Nothing is dispatched — same rule as the
// feed poller (Vikunja #258). A page that announced its own change would be a
// nag, and "the docs page changed" is not worth interrupting anyone for.
//
// Dedup is by content hash, so a page that re-renders identically writes nothing
// and a rotating ad slot does not count as news.
// WatchConfig — one page to keep an eye on.
type WatchConfig struct {
Name string // note source is "crawl:<Name>"
URL string // http(s), guarded by the fetcher
Interval time.Duration // 0 ⇒ Watcher's default
}
// Notes is core's note-writing half (same shape as ipc.CoreAPI's method).
type Notes interface {
WriteNote(ctx context.Context, ts time.Time, text string, embedding []float32, source string) (int64, error)
}
// Hashes remembers the last text hash per watch, durably, so a restart does not
// re-note an unchanged page. The daemon backs this with config facts
// ("crawl:hash:<name>").
type Hashes interface {
LastHash(ctx context.Context, name string) (string, error)
SetHash(ctx context.Context, name, hash string) error
}
// Embedder embeds a note on its way into the store. nil ⇒ no vector.
type Embedder interface {
Embed(ctx context.Context, text string) ([]float32, error)
}
// DefaultWatchInterval — pages change slowly, and every check is a request in
// someone's log.
const DefaultWatchInterval = 6 * time.Hour
// Watcher re-reads watched pages on their interval.
type Watcher struct {
c *Crawler
watches []WatchConfig
notes Notes
hashes Hashes
embed Embedder
interval time.Duration
nextDue map[string]time.Time
}
// NewWatcher wires the scheduled half, or returns nil when there is nothing to
// watch. Callers check for nil: no watches, no goroutine, no request.
func NewWatcher(c *Crawler, watches []WatchConfig, notes Notes, hashes Hashes, embed Embedder, defaultInterval time.Duration) *Watcher {
if c == nil || notes == nil {
return nil
}
var valid []WatchConfig
for _, w := range watches {
if strings.TrimSpace(w.Name) == "" || strings.TrimSpace(w.URL) == "" {
log.Printf("crawl: skipping a watch with no name or no url")
continue
}
valid = append(valid, w)
}
if len(valid) == 0 {
return nil
}
if defaultInterval <= 0 {
defaultInterval = DefaultWatchInterval
}
return &Watcher{
c: c, watches: valid, notes: notes, hashes: hashes, embed: embed,
interval: defaultInterval, nextDue: map[string]time.Time{},
}
}
// Watches returns the configured watches.
func (w *Watcher) Watches() []WatchConfig { return w.watches }
// CheckDue re-reads every watch whose interval elapsed and returns how many
// notes were written. Errors are logged per watch, never returned: one dead page
// must not stop the others.
func (w *Watcher) CheckDue(ctx context.Context, now time.Time) int {
written := 0
for _, watch := range w.watches {
if due, ok := w.nextDue[watch.Name]; ok && now.Before(due) {
continue
}
interval := watch.Interval
if interval <= 0 {
interval = w.interval
}
w.nextDue[watch.Name] = now.Add(interval)
changed, err := w.Check(ctx, watch, now)
if err != nil {
log.Printf("crawl: watch %s: %v", watch.Name, err)
continue
}
if changed {
log.Printf("crawl: watch %s: page changed, noted", watch.Name)
written++
}
}
return written
}
// Check re-reads one watch now and reports whether it wrote a note.
func (w *Watcher) Check(ctx context.Context, watch WatchConfig, now time.Time) (bool, error) {
page, err := w.c.Page(ctx, watch.URL)
if err != nil {
return false, err
}
// Title included: a page whose headline changed has changed.
h := Hash(page.Title + "\n" + page.Text)
if w.hashes != nil {
prev, err := w.hashes.LastHash(ctx, watch.Name)
if err != nil {
log.Printf("crawl: watch %s: read hash: %v", watch.Name, err)
}
if prev == h {
return false, nil
}
}
text := NoteText(watch, page)
var vec []float32
if w.embed != nil {
v, err := w.embed.Embed(ctx, text)
if err != nil {
log.Printf("crawl: watch %s: embed: %v", watch.Name, err)
} else {
vec = v
}
}
if _, err := w.notes.WriteNote(ctx, now, text, vec, SourceFor(watch.Name)); err != nil {
return false, fmt.Errorf("write note: %w", err)
}
if w.hashes != nil {
if err := w.hashes.SetHash(ctx, watch.Name, h); err != nil {
log.Printf("crawl: watch %s: save hash: %v", watch.Name, err)
}
}
return true, nil
}
// SourceFor is the note source for a watch, and SourcePrefix is what the answer
// path matches to recognise one.
func SourceFor(name string) string { return SourcePrefix + name }
// SourcePrefix — provenance for anything read off the network on a schedule.
const SourcePrefix = "crawl:"
// noteRunes — how much of a watched page goes into a note. Shorter than what the
// on-demand path reads: a note is a record of a change, not an archive.
const noteRunes = 800
// NoteText renders a watched page as a note body.
func NoteText(watch WatchConfig, page Page) string {
var b strings.Builder
if page.Title != "" {
b.WriteString(page.Title)
} else {
b.WriteString(watch.Name)
}
b.WriteString("\n")
b.WriteString(TrimRunes(page.Text, noteRunes))
b.WriteString("\n")
b.WriteString(watch.URL)
return b.String()
}
+117
View File
@@ -0,0 +1,117 @@
package crawl
import (
"context"
"strings"
"testing"
"time"
)
type note struct {
text string
source string
}
type fakeNotes struct{ notes []note }
func (n *fakeNotes) WriteNote(_ context.Context, _ time.Time, text string, _ []float32, source string) (int64, error) {
n.notes = append(n.notes, note{text, source})
return int64(len(n.notes)), nil
}
type fakeHashes struct{ m map[string]string }
func newHashes() *fakeHashes { return &fakeHashes{m: map[string]string{}} }
func (f *fakeHashes) LastHash(_ context.Context, name string) (string, error) {
return f.m[name], nil
}
func (f *fakeHashes) SetHash(_ context.Context, name, h string) error { f.m[name] = h; return nil }
var t0 = time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC)
func TestWatchNotesAChangedPage(t *testing.T) {
f := &fakeFetcher{pages: map[string]Response{
"https://example.org/docs": {Body: []byte(htmlPage)},
}}
notes := &fakeNotes{}
hashes := newHashes()
w := NewWatcher(newTestCrawler(f), []WatchConfig{{Name: "docs", URL: "https://example.org/docs"}},
notes, hashes, nil, time.Hour)
if w == nil {
t.Fatal("NewWatcher returned nil for a configured watch")
}
if n := w.CheckDue(context.Background(), t0); n != 1 {
t.Fatalf("first check wrote %d notes, want 1", n)
}
if notes.notes[0].source != "crawl:docs" {
t.Errorf("source = %q, want crawl:docs", notes.notes[0].source)
}
if !strings.Contains(notes.notes[0].text, "https://example.org/docs") {
t.Errorf("note does not carry the url: %q", notes.notes[0].text)
}
// Unchanged page, interval elapsed: nothing written.
if n := w.CheckDue(context.Background(), t0.Add(2*time.Hour)); n != 0 {
t.Fatalf("an unchanged page wrote %d notes", n)
}
// Changed page: one note.
f.pages["https://example.org/docs"] = Response{Body: []byte(strings.Replace(htmlPage, "синее", "серое", 1))}
if n := w.CheckDue(context.Background(), t0.Add(4*time.Hour)); n != 1 {
t.Fatalf("a changed page wrote %d notes, want 1", n)
}
}
func TestWatchIntervalIsRespected(t *testing.T) {
f := &fakeFetcher{pages: map[string]Response{"https://example.org/d": {Body: []byte(htmlPage)}}}
w := NewWatcher(newTestCrawler(f), []WatchConfig{{Name: "d", URL: "https://example.org/d", Interval: time.Hour}},
&fakeNotes{}, newHashes(), nil, 0)
w.CheckDue(context.Background(), t0)
before := len(f.calls)
w.CheckDue(context.Background(), t0.Add(time.Minute))
if len(f.calls) != before {
t.Fatal("the page was re-read inside its interval")
}
}
// The hash is durable so a restart does not re-note an unchanged page.
func TestWatchHashSurvivesRestart(t *testing.T) {
f := &fakeFetcher{pages: map[string]Response{"https://example.org/d": {Body: []byte(htmlPage)}}}
hashes := newHashes()
watches := []WatchConfig{{Name: "d", URL: "https://example.org/d"}}
NewWatcher(newTestCrawler(f), watches, &fakeNotes{}, hashes, nil, time.Hour).CheckDue(context.Background(), t0)
notes2 := &fakeNotes{}
NewWatcher(newTestCrawler(f), watches, notes2, hashes, nil, time.Hour).CheckDue(context.Background(), t0.Add(time.Hour))
if len(notes2.notes) != 0 {
t.Fatalf("a fresh watcher re-noted an unchanged page: %q", notes2.notes[0].text)
}
}
func TestWatchDeadPageDoesNotStopTheOthers(t *testing.T) {
f := &fakeFetcher{pages: map[string]Response{"https://example.org/live": {Body: []byte(htmlPage)}}}
notes := &fakeNotes{}
w := NewWatcher(newTestCrawler(f), []WatchConfig{
{Name: "dead", URL: "https://example.org/gone"},
{Name: "live", URL: "https://example.org/live"},
}, notes, newHashes(), nil, time.Hour)
if n := w.CheckDue(context.Background(), t0); n != 1 {
t.Fatalf("wrote %d notes, want 1 (the live page)", n)
}
if notes.notes[0].source != "crawl:live" {
t.Fatalf("source = %q", notes.notes[0].source)
}
}
func TestNoWatchesMeansNoWatcher(t *testing.T) {
c := newTestCrawler(&fakeFetcher{})
if NewWatcher(c, nil, &fakeNotes{}, nil, nil, 0) != nil {
t.Fatal("no watches must mean no watcher")
}
if NewWatcher(nil, []WatchConfig{{Name: "a", URL: "u"}}, &fakeNotes{}, nil, nil, 0) != nil {
t.Fatal("no crawler must mean no watcher")
}
if NewWatcher(c, []WatchConfig{{Name: "", URL: ""}}, &fakeNotes{}, nil, nil, 0) != nil {
t.Fatal("a watch with no name or url is not a configuration")
}
}
+226
View File
@@ -0,0 +1,226 @@
package email
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
"github.com/kami/maven/internal/llm"
"github.com/kami/maven/internal/persona"
)
// Extraction — turning one mail into task CANDIDATES, and nothing else.
//
// The output of this file can only ever become rows in `tasks` with status
// "candidate" (store.TaskCandidate), written through the one intake seam
// (ipc.CaptureTaskReq, Vikunja #130). That bound is the whole design:
//
// - No reminder. A reminder FIRES; it speaks to him unprompted. A 1.7B that
// misreads "встреча была в четверг" as a future appointment would then wake
// him up about it. A candidate that is wrong is a line on a review page he
// dismisses in one click, which is the correct cost of a model being wrong
// about someone's mail.
// - No fact. A fact is a claim Maven will later recite as true. Nothing read
// out of a marketing mail deserves that standing.
// - No calendar event, no note, no action. Extraction writes candidates or
// writes nothing.
//
// The due date the model may return is stored on the candidate (tasks.due_ts),
// which no scheduler reads — it is there so the review page can sort by it.
//
// Privacy: the mail text goes to the resident model on this box and nowhere
// else. It is never search input (CLAUDE.md: "his notes and facts are never
// search input" — mail is the same class), and Evidence keeps only the subject
// line, so the review page shows him where a candidate came from without the
// store growing a copy of his mailbox.
// MaxCandidates — at most this many candidates per message, enforced by the
// grammar. A mail with four tasks in it is a mail he has to read himself; a
// model allowed ten will produce ten.
const MaxCandidates = 3
// SourcePrefix — provenance for everything this package captures. The mailbox
// name is appended: "email:INBOX". Same vocabulary as tap:voice / poll:netdata.
const SourcePrefix = "email:"
// Candidate — one piece of work the model thinks the mail is asking for.
type Candidate struct {
Text string `json:"text"`
// Due — "YYYY-MM-DD" or empty. A date the model read out of the text, not a
// date it computed: relative wording ("до пятницы") is left in Text, because
// a small model resolving "пятница" against today's date gets it wrong often
// enough that a stored wrong date is worse than no date.
Due string `json:"due"`
}
// Completer — the llama-server seam, same shape memeval and the router use, so
// the one resident model serves this caller too.
type Completer interface {
Complete(ctx context.Context, r llm.Req) (string, error)
}
// Extractor reads a message and returns candidates. It holds no store and no
// writer on purpose: this type cannot persist anything, so "extraction never
// acts" is a property of the code, not of a review.
type Extractor struct {
llm Completer
// MaxCandidates — 0 ⇒ MaxCandidates.
max int
// ContextBlock — the shared persona block, optional. Extraction output is
// not spoken, so the persona matters less here than in the phraser; it is
// wired anyway so a candidate reads in her voice on the review page.
contextBlock func() string
}
func NewExtractor(c Completer, max int, contextBlock func() string) *Extractor {
if max <= 0 || max > MaxCandidates {
max = MaxCandidates
}
return &Extractor{llm: c, max: max, contextBlock: contextBlock}
}
// extractGrammar — GBNF pinning the answer to a bounded array of fixed-shape
// candidates. Same reasoning as memeval's evalGrammar and the router's
// routeGrammar: the shape and the length bound are what keep a small model from
// drifting into prose or spending the token budget repeating one field.
//
// The empty array is reachable, deliberately: most mail contains no task, and a
// model with no way to say "nothing" invents something.
const extractGrammar = `
root ::= "[" ws (item ("," ws item){0,2})? ws "]"
item ::= "{" ws "\"text\"" ws ":" ws text "," ws "\"due\"" ws ":" ws due ws "}"
text ::= "\"" ([^"\\] | "\\" .){1,120} "\""
due ::= "\"\"" | "\"" [0-9]{4} "-" [0-9]{2} "-" [0-9]{2} "\""
ws ::= [ \t\n]*
`
// extractSystem — the extraction prompt.
//
// Written around the two failure modes a small model has on this task: it
// summarises when asked to extract (turning a mail into "письмо от Антона"),
// and it invents an obligation from any polite closing sentence. Hence the
// insistence on a verb phrase, and the explicit permission to return [].
const extractSystem = `Ты читаешь одно письмо из его почты и достаёшь из него дела, которые письмо от него требует.
Правила:
- Отвечай ТОЛЬКО массивом JSON. Каждый элемент: {"text": "...", "due": "ГГГГ-ММ-ДД" или ""}.
- text — короткая формулировка дела по-русски, с глаголом: "оплатить счёт за интернет", "отправить акт". Не пересказывай письмо и не описывай его.
- Дело — это то, что должен сделать ОН. Рассылка, реклама, уведомление, отчёт, письмо «просто к сведению» — дел не содержат.
- Если письмо ничего от него не требует, верни пустой массив []. Это нормальный ответ, так бывает чаще всего.
- Ничего не придумывай. Если срока в письме нет — "".
- due заполняй только когда в письме стоит конкретная дата. Слова вроде «до пятницы» оставь в text, дату не вычисляй.
- Максимум три дела. Лучше одно точное, чем три общих.`
// Extract returns the candidates in one message.
//
// Junk is refused without an LLM call — cheapest possible defence, and the
// reason the header filter exists. An empty message (no subject, no body) is
// likewise not worth a round trip.
//
// A parse failure is an error the caller logs and moves past. It is never
// silently turned into zero candidates, because "the model went off the rails"
// and "the mail contains no task" want different reactions from a human reading
// the log.
func (e *Extractor) Extract(ctx context.Context, msg Message) ([]Candidate, error) {
if msg.Junk {
return nil, nil
}
user := renderForModel(msg)
if user == "" {
return nil, nil
}
raw, err := e.llm.Complete(ctx, llm.Req{
System: persona.Prepend(e.contextBlock, extractSystem),
User: user,
Grammar: extractGrammar,
MaxTokens: 512,
RepeatPenalty: 1.1,
})
if err != nil {
return nil, fmt.Errorf("email: extract: %w", err)
}
items, err := parseCandidates(raw)
if err != nil {
// The raw reply is NOT in the error: it is a transformation of his mail,
// and this error reaches the daemon log.
return nil, fmt.Errorf("email: extract: unparsable reply (%d bytes)", len(raw))
}
out := make([]Candidate, 0, len(items))
seen := map[string]bool{}
for _, it := range items {
it.Text = strings.TrimSpace(it.Text)
if it.Text == "" {
continue
}
key := strings.ToLower(strings.Join(strings.Fields(it.Text), " "))
if seen[key] {
continue // the model repeating itself is not two tasks
}
seen[key] = true
if _, ok := ParseDue(it.Due); !ok {
it.Due = "" // a date the grammar allowed but the calendar does not
}
out = append(out, it)
if len(out) >= e.max {
break
}
}
return out, nil
}
// renderForModel is the user turn: subject, sender and body, labelled. Only
// these three fields — no headers, no recipient list, no message-id, nothing
// that would let the model start reasoning about routing metadata.
func renderForModel(msg Message) string {
var b strings.Builder
if msg.From != "" {
fmt.Fprintf(&b, "От: %s\n", msg.From)
}
if msg.Subject != "" {
fmt.Fprintf(&b, "Тема: %s\n", msg.Subject)
}
if msg.Body != "" {
fmt.Fprintf(&b, "\n%s\n", msg.Body)
}
if msg.Subject == "" && msg.Body == "" {
return ""
}
return b.String()
}
// parseCandidates decodes the grammar-constrained reply, tolerating the
// wrappers a Thinking model sometimes leaves around it (a fenced block, or
// leading reasoning before the array).
func parseCandidates(raw string) ([]Candidate, error) {
s := strings.TrimSpace(raw)
if i := strings.Index(s, "["); i > 0 {
s = s[i:]
}
if j := strings.LastIndex(s, "]"); j >= 0 {
s = s[:j+1]
}
var out []Candidate
if err := json.Unmarshal([]byte(s), &out); err != nil {
return nil, err
}
return out, nil
}
// ParseDue turns the model's "YYYY-MM-DD" into a time in UTC. Exported because
// the daemon-side intake stores it on the candidate.
//
// The zero-value/empty case returns ok=false rather than an error: no date is
// the common answer, not a failure.
func ParseDue(s string) (time.Time, bool) {
s = strings.TrimSpace(s)
if s == "" {
return time.Time{}, false
}
t, err := time.Parse("2006-01-02", s)
if err != nil {
return time.Time{}, false
}
return t, true
}
+143
View File
@@ -0,0 +1,143 @@
package email
import (
"context"
"strings"
"testing"
"github.com/kami/maven/internal/llm"
)
// fakeLLM returns a canned reply and records the request, so a test can assert
// on the grammar and on what of the mail was sent.
type fakeLLM struct {
reply string
err error
got llm.Req
calls int
}
func (f *fakeLLM) Complete(_ context.Context, r llm.Req) (string, error) {
f.calls++
f.got = r
return f.reply, f.err
}
func msgFor(subject, body string) Message {
return Message{UID: 1, From: "anton@example.org", Subject: subject, Body: body}
}
func TestExtractCandidates(t *testing.T) {
f := &fakeLLM{reply: `[{"text":"отправить акт","due":""},{"text":"оплатить счёт","due":"2026-08-05"}]`}
e := NewExtractor(f, 0, nil)
got, err := e.Extract(context.Background(), msgFor("Акт и счёт", "Надо отправить акт и оплатить счёт до 5 августа."))
if err != nil {
t.Fatalf("extract: %v", err)
}
if len(got) != 2 {
t.Fatalf("got %d candidates, want 2: %+v", len(got), got)
}
if got[0].Text != "отправить акт" || got[1].Due != "2026-08-05" {
t.Errorf("candidates = %+v", got)
}
if f.got.Grammar == "" {
t.Error("extraction must be grammar-constrained")
}
// The subject and body go to the model; nothing else about the message does.
if !strings.Contains(f.got.User, "Акт и счёт") || !strings.Contains(f.got.User, "оплатить счёт") {
t.Errorf("user turn = %q", f.got.User)
}
}
func TestExtractEmptyArrayIsNotAnError(t *testing.T) {
f := &fakeLLM{reply: "[]"}
got, err := NewExtractor(f, 0, nil).Extract(context.Background(), msgFor("FYI", "Просто к сведению."))
if err != nil || len(got) != 0 {
t.Fatalf("got (%v, %v), want (empty, nil) — no task is the normal answer", got, err)
}
}
// Junk must never reach the model: the header filter exists so the resident
// model is not spent on newsletters.
func TestExtractSkipsJunkWithoutCallingModel(t *testing.T) {
f := &fakeLLM{reply: `[{"text":"купить всё со скидкой","due":""}]`}
msg := msgFor("Скидки", "Sale!")
msg.Junk = true
got, err := NewExtractor(f, 0, nil).Extract(context.Background(), msg)
if err != nil || got != nil {
t.Fatalf("got (%v, %v), want (nil, nil)", got, err)
}
if f.calls != 0 {
t.Errorf("model called %d times for junk, want 0", f.calls)
}
}
func TestExtractEmptyMessageIsNotSent(t *testing.T) {
f := &fakeLLM{reply: "[]"}
if _, err := NewExtractor(f, 0, nil).Extract(context.Background(), Message{UID: 3}); err != nil {
t.Fatalf("extract: %v", err)
}
if f.calls != 0 {
t.Errorf("model called %d times for an empty message, want 0", f.calls)
}
}
func TestExtractCaps(t *testing.T) {
f := &fakeLLM{reply: `[{"text":"a","due":""},{"text":"b","due":""},{"text":"c","due":""}]`}
got, err := NewExtractor(f, 2, nil).Extract(context.Background(), msgFor("s", "b"))
if err != nil {
t.Fatalf("extract: %v", err)
}
if len(got) != 2 {
t.Errorf("got %d, want the configured cap of 2", len(got))
}
}
func TestExtractDropsRepeatsAndBadDates(t *testing.T) {
f := &fakeLLM{reply: `[{"text":"Отправить акт","due":"2026-02-31"},{"text":"отправить акт","due":""},{"text":" ","due":""}]`}
got, err := NewExtractor(f, 0, nil).Extract(context.Background(), msgFor("s", "b"))
if err != nil {
t.Fatalf("extract: %v", err)
}
if len(got) != 1 {
t.Fatalf("got %d candidates, want 1 (repeat and blank dropped): %+v", len(got), got)
}
if got[0].Due != "" {
t.Errorf("due = %q, want empty — 2026-02-31 is not a date", got[0].Due)
}
}
// A Thinking model sometimes wraps the array; and when it emits something
// unparsable the caller must hear about it rather than see "no tasks".
func TestParseCandidatesTolerance(t *testing.T) {
got, err := parseCandidates("думаю... [{\"text\":\"x\",\"due\":\"\"}] всё")
if err != nil || len(got) != 1 || got[0].Text != "x" {
t.Fatalf("got (%+v, %v)", got, err)
}
if _, err := parseCandidates("нет никакого JSON"); err == nil {
t.Error("unparsable output must be an error")
}
}
func TestExtractParseErrorHidesMailText(t *testing.T) {
f := &fakeLLM{reply: "он просил отправить акт, вот такой ответ"}
_, err := NewExtractor(f, 0, nil).Extract(context.Background(), msgFor("Акт", "секретный текст"))
if err == nil {
t.Fatal("want an error")
}
if strings.Contains(err.Error(), "акт") || strings.Contains(err.Error(), "секретный") {
t.Errorf("error text leaks mail content: %v", err)
}
}
func TestParseDue(t *testing.T) {
if _, ok := ParseDue(""); ok {
t.Error("empty due must be (zero, false)")
}
if got, ok := ParseDue("2026-08-05"); !ok || got.Year() != 2026 || got.Month() != 8 || got.Day() != 5 {
t.Errorf("ParseDue = (%v, %v)", got, ok)
}
if _, ok := ParseDue("05.08.2026"); ok {
t.Error("a non-ISO date must not parse")
}
}
+98
View File
@@ -0,0 +1,98 @@
package email
import (
"fmt"
"time"
)
// FetchSince is the whole read path in one call: connect, log in, examine the
// mailbox read-only, list what arrived since a date, fetch and parse the ones
// the caller has not seen, log out.
//
// It is a function rather than a long-lived object because a mail poller should
// not hold an authenticated session (and therefore his credential in a live TLS
// state) between polls. Connect, read, drop.
//
// skip decides which UIDs are already known — the poller's seen-set. max bounds
// one poll: a mailbox that received 400 messages overnight must not turn into
// 400 LLM calls, and the newest max are the ones a task could still be hiding
// in. Junk messages are returned too, flagged, so the caller can mark them seen
// without a second protocol round.
type FetchSince struct {
Addr string // host or host:993
User string
Mailbox string // e.g. "INBOX"
Timeout time.Duration
Since time.Time
Max int
Skip func(uid uint32) bool
}
// Run performs one read. password is passed here, not stored in the struct, so
// the configuration of a mailbox and the secret for it are never the same value
// sitting in the same place.
func (f FetchSince) Run(password string) ([]Message, error) {
return f.RunWith(password, nil)
}
// RunWith is Run with an explicit connection function, which is how the reader
// daemon and the tests substitute an in-process server. nil ⇒ Dial, i.e.
// implicit TLS with certificate verification; there is no configuration path
// that reaches this, so no deployment can end up talking cleartext IMAP.
func (f FetchSince) RunWith(password string, dial func(addr string, timeout time.Duration) (*Conn, error)) ([]Message, error) {
if f.Addr == "" || f.User == "" || f.Mailbox == "" {
return nil, fmt.Errorf("email: mailbox not configured (addr/user/mailbox)")
}
if dial == nil {
dial = Dial
}
c, err := dial(f.Addr, f.Timeout)
if err != nil {
return nil, err
}
defer c.Close()
if err := c.Login(f.User, password); err != nil {
return nil, err
}
defer c.Logout()
if err := c.Select(f.Mailbox); err != nil {
return nil, err
}
uids, err := c.SearchSince(f.Since)
if err != nil {
return nil, err
}
// Newest UIDs first — IMAP hands them back ascending, and when Max clips the
// list the recent mail is what matters.
wanted := make([]uint32, 0, len(uids))
for i := len(uids) - 1; i >= 0; i-- {
if f.Skip != nil && f.Skip(uids[i]) {
continue
}
wanted = append(wanted, uids[i])
if f.Max > 0 && len(wanted) >= f.Max {
break
}
}
out := make([]Message, 0, len(wanted))
for _, uid := range wanted {
raw, err := c.Fetch(uid)
if err != nil {
// One unreadable message does not abandon the poll; the rest of the
// mailbox is still worth reading. The error names the UID, not the
// message.
return out, fmt.Errorf("email: fetch uid %d: %w", uid, err)
}
if len(raw) == 0 {
continue // vanished between SEARCH and FETCH
}
msg, err := ParseMessage(uid, raw)
if err != nil {
continue // unparsable headers — nothing to review, skip silently
}
out = append(out, msg)
}
return out, nil
}
+49
View File
@@ -0,0 +1,49 @@
package email
import (
"net"
"strings"
"testing"
"time"
)
func TestFetchSinceRun(t *testing.T) {
mk := func(subject string) string {
return "Subject: " + subject + "\r\nContent-Type: text/plain; charset=utf-8\r\n\r\nbody\r\n"
}
f := &fakeIMAP{
uids: []uint32{1, 2, 3},
msgs: map[uint32]string{1: mk("one"), 2: mk("two"), 3: mk("three")},
}
fs := FetchSince{
Addr: "mail.example:993", User: "kami", Mailbox: "INBOX",
Timeout: 5 * time.Second,
Since: time.Date(2026, 7, 30, 0, 0, 0, 0, time.UTC),
Max: 2,
Skip: func(uid uint32) bool { return uid == 3 },
}
msgs, err := fs.RunWith("secret", func(addr string, timeout time.Duration) (*Conn, error) {
cli, srv := net.Pipe()
go f.serve(t, srv)
return NewConn(cli, timeout)
})
if err != nil {
t.Fatalf("run: %v", err)
}
// Newest first, the already-seen UID skipped, Max respected.
if len(msgs) != 2 {
t.Fatalf("got %d messages, want 2: %+v", len(msgs), msgs)
}
if msgs[0].Subject != "two" || msgs[1].Subject != "one" {
t.Errorf("subjects = %q,%q, want two,one (newest first)", msgs[0].Subject, msgs[1].Subject)
}
if strings.Contains(strings.Join(f.cmds, " "), "UID FETCH 3") {
t.Error("a skipped UID must not be fetched again")
}
}
func TestFetchSinceRequiresConfig(t *testing.T) {
if _, err := (FetchSince{}).Run("secret"); err == nil {
t.Fatal("an unconfigured mailbox must not be read")
}
}
+280
View File
@@ -0,0 +1,280 @@
package email
import (
"bufio"
"crypto/tls"
"fmt"
"io"
"net"
"regexp"
"strconv"
"strings"
"time"
)
// A minimal IMAP4rev1 client — LOGIN, SELECT, UID SEARCH, UID FETCH with
// BODY.PEEK, LOGOUT, and nothing else.
//
// Why hand-rolled instead of go-imap: the whole surface Maven needs is five
// commands, and this is the one code path that holds his mailbox credential and
// reads his private mail. A ~200-line client with no dependencies is auditable
// in one sitting; a general-purpose IMAP library is a much larger amount of
// code doing much more than we asked, in the most sensitive place in the tree.
// If IDLE, CONDSTORE or server-side threading ever become worth having, that
// trade should be re-made deliberately.
//
// BODY.PEEK[] rather than BODY[] is load-bearing: Maven reads his mail and must
// leave no trace of having done so. Reading a message here does not mark it
// \Seen, so the unread state in his own mail client stays his.
// DefaultIMAPPort — implicit-TLS IMAP. There is no cleartext and no STARTTLS
// path in this client: an option to send his password over a plain socket is an
// option to get it wrong once.
const DefaultIMAPPort = "993"
// Conn — one authenticated IMAP connection. Not safe for concurrent use; the
// poller drives one connection at a time.
type Conn struct {
rwc io.ReadWriteCloser
r *bufio.Reader
tag int
timeout time.Duration
}
// Dial opens an implicit-TLS connection and reads the server greeting.
func Dial(addr string, timeout time.Duration) (*Conn, error) {
host, _, err := net.SplitHostPort(addr)
if err != nil {
host, addr = addr, net.JoinHostPort(addr, DefaultIMAPPort)
}
d := &net.Dialer{Timeout: timeout}
// ServerName is set from the host we asked for: certificate verification is
// the only thing standing between his password and a MITM on the way out.
c, err := tls.DialWithDialer(d, "tcp", addr, &tls.Config{ServerName: host, MinVersion: tls.VersionTLS12})
if err != nil {
return nil, fmt.Errorf("email: dial %s: %w", addr, err)
}
return NewConn(c, timeout)
}
// NewConn wraps an already-open stream (the tests speak IMAP over a pipe) and
// consumes the greeting.
func NewConn(rwc io.ReadWriteCloser, timeout time.Duration) (*Conn, error) {
c := &Conn{rwc: rwc, r: bufio.NewReaderSize(rwc, 64<<10), timeout: timeout}
line, err := c.readLine()
if err != nil {
return nil, fmt.Errorf("email: greeting: %w", err)
}
if !strings.HasPrefix(line, "* OK") && !strings.HasPrefix(line, "* PREAUTH") {
c.rwc.Close()
return nil, fmt.Errorf("email: server refused connection: %s", line)
}
return c, nil
}
func (c *Conn) Close() error { return c.rwc.Close() }
// Login authenticates with LOGIN. The password is passed as an argument and
// never stored on the Conn: nothing in this package keeps a credential alive
// past the command that uses it, so no struct dump or panic trace can carry it.
func (c *Conn) Login(user, pass string) error {
// The command line itself is never logged (see exec) — a LOGIN line IS the
// credential.
if _, err := c.exec(fmt.Sprintf("LOGIN %s %s", quote(user), quote(pass))); err != nil {
return fmt.Errorf("email: login: %w", err)
}
return nil
}
// Select opens a mailbox read-only. EXAMINE, not SELECT: read-only at the
// protocol level means no command in this session can change a flag, expunge a
// message, or move anything, even by mistake.
func (c *Conn) Select(mailbox string) error {
if _, err := c.exec(fmt.Sprintf("EXAMINE %s", quote(mailbox))); err != nil {
return fmt.Errorf("email: examine %s: %w", mailbox, err)
}
return nil
}
// SearchSince returns the UIDs of messages received on or after since. An
// unlimited search is not offered: the first poll against a years-old mailbox
// would otherwise fetch everything and hand a decade of mail to the model.
//
// The IMAP SINCE key has date granularity (and compares the server's internal
// date), so the result can include messages slightly older than since. The
// caller dedupes by UID anyway, so a wider window costs one extra fetch.
func (c *Conn) SearchSince(since time.Time) ([]uint32, error) {
cmd := fmt.Sprintf("UID SEARCH SINCE %s", since.Format("2-Jan-2006"))
lines, err := c.exec(cmd)
if err != nil {
return nil, fmt.Errorf("email: search: %w", err)
}
var uids []uint32
for _, l := range lines {
rest, ok := untagged(l, "SEARCH")
if !ok {
continue
}
for _, f := range strings.Fields(rest) {
n, err := strconv.ParseUint(f, 10, 32)
if err == nil {
uids = append(uids, uint32(n))
}
}
}
return uids, nil
}
var literalSize = regexp.MustCompile(`\{(\d+)\}$`)
// Fetch returns the raw RFC 5322 bytes of one message, by UID.
//
// Returns (nil, nil) when the UID no longer exists — a message he deleted
// between SEARCH and FETCH is normal, not an error.
func (c *Conn) Fetch(uid uint32) ([]byte, error) {
tag := c.nextTag()
if err := c.send(fmt.Sprintf("%s UID FETCH %d (BODY.PEEK[])", tag, uid)); err != nil {
return nil, err
}
var raw []byte
for {
line, err := c.readLine()
if err != nil {
return nil, fmt.Errorf("email: fetch %d: %w", uid, err)
}
if done, err := c.tagged(tag, line); done {
if err != nil {
return nil, fmt.Errorf("email: fetch %d: %w", uid, err)
}
return raw, nil
}
m := literalSize.FindStringSubmatch(strings.TrimSpace(line))
if m == nil {
continue
}
n, err := strconv.Atoi(m[1])
if err != nil {
continue
}
buf := make([]byte, n)
if _, err := io.ReadFull(c.r, buf); err != nil {
return nil, fmt.Errorf("email: fetch %d: literal: %w", uid, err)
}
if raw == nil {
raw = buf
}
}
}
// Logout ends the session politely. A failure is not worth reporting — the
// connection is being closed either way.
func (c *Conn) Logout() {
_, _ = c.exec("LOGOUT")
}
// ---- protocol plumbing -----------------------------------------------------
func (c *Conn) nextTag() string {
c.tag++
return fmt.Sprintf("a%03d", c.tag)
}
// exec sends one command and returns the untagged response lines.
//
// Neither the command nor the response is ever logged here. LOGIN goes through
// this function, and a debug line "sent: a001 LOGIN ..." is how a credential
// ends up in a log file forever.
func (c *Conn) exec(cmd string) ([]string, error) {
tag := c.nextTag()
if err := c.send(tag + " " + cmd); err != nil {
return nil, err
}
var lines []string
for {
line, err := c.readLine()
if err != nil {
return nil, err
}
if done, err := c.tagged(tag, line); done {
return lines, err
}
lines = append(lines, line)
// A response line may carry a literal (e.g. a header FETCH). Nothing we
// send asks for one outside Fetch, but skip it if it appears so the
// stream stays aligned.
if m := literalSize.FindStringSubmatch(strings.TrimSpace(line)); m != nil {
if n, err := strconv.Atoi(m[1]); err == nil {
if _, err := io.CopyN(io.Discard, c.r, int64(n)); err != nil {
return nil, err
}
}
}
}
}
// tagged reports whether line completes the command with this tag, and turns a
// NO/BAD completion into an error. The error text is the server's, which never
// echoes a password.
func (c *Conn) tagged(tag, line string) (bool, error) {
if !strings.HasPrefix(line, tag+" ") {
return false, nil
}
rest := strings.TrimSpace(line[len(tag):])
switch {
case strings.HasPrefix(rest, "OK"):
return true, nil
case strings.HasPrefix(rest, "NO"), strings.HasPrefix(rest, "BAD"):
return true, fmt.Errorf("server said: %s", rest)
default:
return true, fmt.Errorf("unexpected completion: %s", rest)
}
}
func (c *Conn) send(line string) error {
c.setDeadline()
if _, err := io.WriteString(c.rwc, line+"\r\n"); err != nil {
return fmt.Errorf("email: write: %w", err)
}
return nil
}
func (c *Conn) readLine() (string, error) {
c.setDeadline()
line, err := c.r.ReadString('\n')
if err != nil {
return "", err
}
return strings.TrimRight(line, "\r\n"), nil
}
// setDeadline applies the per-connection timeout when the transport supports
// one. A hung IMAP server must not park the poller forever.
func (c *Conn) setDeadline() {
if c.timeout <= 0 {
return
}
if d, ok := c.rwc.(interface{ SetDeadline(time.Time) error }); ok {
_ = d.SetDeadline(time.Now().Add(c.timeout))
}
}
// untagged splits "* SEARCH 1 2 3" into its payload when the key matches.
func untagged(line, key string) (string, bool) {
if !strings.HasPrefix(line, "* ") {
return "", false
}
rest := strings.TrimSpace(line[2:])
if !strings.HasPrefix(rest, key) {
return "", false
}
return strings.TrimSpace(rest[len(key):]), true
}
// quote renders an IMAP quoted string. Passwords routinely contain characters
// that would otherwise end the argument early, and CR/LF are stripped rather
// than escaped because there is no legal way to send them — a credential file
// with a stray newline must not become a second command.
func quote(s string) string {
s = strings.NewReplacer("\r", "", "\n", "").Replace(s)
return `"` + strings.NewReplacer(`\`, `\\`, `"`, `\"`).Replace(s) + `"`
}
+162
View File
@@ -0,0 +1,162 @@
package email
import (
"bufio"
"fmt"
"net"
"strconv"
"strings"
"testing"
"time"
)
// fakeIMAP is a scripted server: enough of IMAP to exercise the client, and
// nothing more. It records the commands it received so a test can assert on the
// protocol (BODY.PEEK rather than BODY, EXAMINE rather than SELECT).
type fakeIMAP struct {
msgs map[uint32]string
uids []uint32
cmds []string
failOn string // substring of a command to answer NO
}
func (f *fakeIMAP) serve(t *testing.T, c net.Conn) {
t.Helper()
defer c.Close()
fmt.Fprint(c, "* OK fake IMAP ready\r\n")
r := bufio.NewReader(c)
for {
line, err := r.ReadString('\n')
if err != nil {
return
}
line = strings.TrimRight(line, "\r\n")
parts := strings.SplitN(line, " ", 2)
if len(parts) != 2 {
return
}
tag, cmd := parts[0], parts[1]
f.cmds = append(f.cmds, cmd)
if f.failOn != "" && strings.Contains(cmd, f.failOn) {
fmt.Fprintf(c, "%s NO computer says no\r\n", tag)
continue
}
upper := strings.ToUpper(cmd)
switch {
case strings.HasPrefix(upper, "LOGIN"), strings.HasPrefix(upper, "EXAMINE"):
fmt.Fprintf(c, "%s OK done\r\n", tag)
case strings.HasPrefix(upper, "UID SEARCH"):
var ids []string
for _, u := range f.uids {
ids = append(ids, strconv.FormatUint(uint64(u), 10))
}
fmt.Fprintf(c, "* SEARCH %s\r\n", strings.Join(ids, " "))
fmt.Fprintf(c, "%s OK search done\r\n", tag)
case strings.HasPrefix(upper, "UID FETCH"):
uid64, _ := strconv.ParseUint(strings.Fields(cmd)[2], 10, 32)
raw, ok := f.msgs[uint32(uid64)]
if ok {
fmt.Fprintf(c, "* 1 FETCH (UID %d BODY[] {%d}\r\n", uid64, len(raw))
fmt.Fprint(c, raw)
fmt.Fprint(c, ")\r\n")
}
fmt.Fprintf(c, "%s OK fetch done\r\n", tag)
case strings.HasPrefix(upper, "LOGOUT"):
fmt.Fprint(c, "* BYE\r\n")
fmt.Fprintf(c, "%s OK bye\r\n", tag)
return
default:
fmt.Fprintf(c, "%s BAD unknown\r\n", tag)
}
}
}
// dialFake wires a client Conn to an in-process server over net.Pipe.
func dialFake(t *testing.T, f *fakeIMAP) *Conn {
t.Helper()
cli, srv := net.Pipe()
go f.serve(t, srv)
c, err := NewConn(cli, 5*time.Second)
if err != nil {
t.Fatalf("greeting: %v", err)
}
t.Cleanup(func() { c.Close() })
return c
}
func TestIMAPRoundTrip(t *testing.T) {
body := "Subject: hello\r\nContent-Type: text/plain; charset=utf-8\r\n\r\nCall the bank.\r\n"
f := &fakeIMAP{uids: []uint32{4, 9}, msgs: map[uint32]string{4: body, 9: body}}
c := dialFake(t, f)
if err := c.Login("kami", `pa"ss\word`); err != nil {
t.Fatalf("login: %v", err)
}
if err := c.Select("INBOX"); err != nil {
t.Fatalf("select: %v", err)
}
uids, err := c.SearchSince(time.Date(2026, 8, 1, 0, 0, 0, 0, time.UTC))
if err != nil {
t.Fatalf("search: %v", err)
}
if len(uids) != 2 || uids[0] != 4 || uids[1] != 9 {
t.Fatalf("uids = %v, want [4 9]", uids)
}
raw, err := c.Fetch(9)
if err != nil {
t.Fatalf("fetch: %v", err)
}
if string(raw) != body {
t.Errorf("fetched %q, want the literal verbatim", raw)
}
c.Logout()
joined := strings.Join(f.cmds, "\n")
// Read-only at the protocol level, and peeking — Maven must leave no trace
// of having read his mail.
if !strings.Contains(joined, "EXAMINE") || strings.Contains(joined, "SELECT ") {
t.Errorf("want EXAMINE (read-only), got:\n%s", joined)
}
if !strings.Contains(joined, "BODY.PEEK[]") {
t.Errorf("want BODY.PEEK, got:\n%s", joined)
}
// The password must have been quoted and escaped, not truncated at the quote.
if !strings.Contains(joined, `"pa\"ss\\word"`) {
t.Errorf("password not quoted correctly:\n%s", joined)
}
// SINCE must carry the IMAP date form.
if !strings.Contains(joined, "SINCE 1-Aug-2026") {
t.Errorf("want a SINCE date, got:\n%s", joined)
}
}
func TestIMAPServerNoIsAnError(t *testing.T) {
f := &fakeIMAP{failOn: "LOGIN"}
c := dialFake(t, f)
err := c.Login("kami", "wrong")
if err == nil {
t.Fatal("a NO completion must be an error")
}
// The error is the server's text; it must not echo the credential.
if strings.Contains(err.Error(), "wrong") {
t.Errorf("error leaks the password: %v", err)
}
}
func TestIMAPFetchMissingUID(t *testing.T) {
f := &fakeIMAP{uids: []uint32{1}, msgs: map[uint32]string{}}
c := dialFake(t, f)
raw, err := c.Fetch(1)
if err != nil {
t.Fatalf("fetch: %v", err)
}
if raw != nil {
t.Errorf("a vanished UID should give nil, got %q", raw)
}
}
func TestQuoteStripsNewlines(t *testing.T) {
if got := quote("pass\r\nA1 LOGOUT"); strings.ContainsAny(got, "\r\n") {
t.Errorf("quote kept a line break: %q", got)
}
}
+80
View File
@@ -0,0 +1,80 @@
package email
import (
"net/mail"
"strings"
)
// The junk filter — the cheapest and most important half of reading mail.
//
// A mailbox is mostly machine-generated: newsletters, receipts nobody acts on,
// social notifications, marketing. Sending all of it to a 1.7B and asking "is
// there a task here" produces confident nonsense at a rate proportional to the
// volume, so junk is decided by HEADERS, before any model sees the message.
//
// The rules are all bulk-mail markers that senders set on themselves, never
// guesses about content:
//
// - List-Unsubscribe / List-Id — by definition a mailing list. If he can
// unsubscribe from it, it is not asking him to do anything.
// - Precedence: bulk|junk|list — the sender declaring itself bulk.
// - Auto-Submitted other than "no" (RFC 3834) — generated by a machine.
// - X-Spam-Flag: YES, X-Spam-Status: Yes — the spam filter upstream already
// decided; we do not second-guess it in the other direction.
// - X-GM-LABELS / X-Gmail-Labels containing a Gmail category — Gmail's own
// Promotions/Social/Forums/Spam classification, when the server sends it.
//
// Deliberately NOT here: sender allow/deny lists and subject keyword matching.
// Both are configuration that ages badly and both would be a place for his
// contacts to end up in a config file. If a real correspondent's mail is being
// dropped, the fix is a rule about a header, not a list of names.
//
// A junk verdict never deletes anything and never touches a flag on the server.
// It means "do not spend the model on this", nothing more.
// junkHeaders — headers whose mere presence marks bulk mail.
var junkPresence = []string{"List-Unsubscribe", "List-Id", "List-Post"}
// gmailCategories — Gmail's category labels, lowercased as they appear in
// X-GM-LABELS. "important" and "inbox" are labels too, and are NOT categories.
// Matching is by these exact tokens (substring is fine — they are namespaced
// and cannot appear in a hand-made label by accident), so a user label named
// "Social Club" is not mistaken for Gmail's Social category.
var gmailCategories = []string{
"category_promotions", "category_social", "category_forums", "category_updates",
`\spam`, `\junk`,
}
// classifyJunk returns whether the message is bulk/automated and why. The
// reason is a short header name, safe to log — it names the marker, never the
// sender or the subject.
func classifyJunk(h mail.Header) (bool, string) {
for _, name := range junkPresence {
if strings.TrimSpace(h.Get(name)) != "" {
return true, strings.ToLower(name)
}
}
switch strings.ToLower(strings.TrimSpace(h.Get("Precedence"))) {
case "bulk", "junk", "list":
return true, "precedence"
}
if v := strings.ToLower(strings.TrimSpace(h.Get("Auto-Submitted"))); v != "" && v != "no" {
return true, "auto-submitted"
}
if strings.EqualFold(strings.TrimSpace(h.Get("X-Spam-Flag")), "yes") {
return true, "x-spam-flag"
}
if v := strings.ToLower(strings.TrimSpace(h.Get("X-Spam-Status"))); strings.HasPrefix(v, "yes") {
return true, "x-spam-status"
}
labels := strings.ToLower(h.Get("X-GM-LABELS") + " " + h.Get("X-Gmail-Labels"))
for _, c := range gmailCategories {
if c == "" {
continue
}
if strings.Contains(labels, c) {
return true, "gmail-category"
}
}
return false, ""
}
+59
View File
@@ -0,0 +1,59 @@
package email
import (
"net/mail"
"strings"
"testing"
)
func headers(t *testing.T, raw string) mail.Header {
t.Helper()
m, err := mail.ReadMessage(strings.NewReader(strings.ReplaceAll(raw, "\n", "\r\n") + "\r\n\r\nbody\r\n"))
if err != nil {
t.Fatalf("read headers: %v", err)
}
return m.Header
}
func TestClassifyJunk(t *testing.T) {
cases := []struct {
name string
raw string
junk bool
reason string
}{
{"personal", "From: a@b.c\nSubject: привет", false, ""},
{"list-unsubscribe", "From: a@b.c\nList-Unsubscribe: <mailto:u@b.c>", true, "list-unsubscribe"},
{"list-id", "From: a@b.c\nList-Id: <golang-nuts.example>", true, "list-id"},
{"precedence bulk", "From: a@b.c\nPrecedence: bulk", true, "precedence"},
{"auto-submitted", "From: a@b.c\nAuto-Submitted: auto-generated", true, "auto-submitted"},
{"auto-submitted no", "From: a@b.c\nAuto-Submitted: no", false, ""},
{"spam flag", "From: a@b.c\nX-Spam-Flag: YES", true, "x-spam-flag"},
{"spam status", "From: a@b.c\nX-Spam-Status: Yes, score=9.1", true, "x-spam-status"},
{"spam status no", "From: a@b.c\nX-Spam-Status: No, score=0.1", false, ""},
{"gmail promo", "From: a@b.c\nX-Gmail-Labels: Inbox,CATEGORY_PROMOTIONS", true, "gmail-category"},
{"user label", "From: a@b.c\nX-Gmail-Labels: Social Club,Important", false, ""},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
junk, reason := classifyJunk(headers(t, c.raw))
if junk != c.junk || reason != c.reason {
t.Errorf("classifyJunk = (%v, %q), want (%v, %q)", junk, reason, c.junk, c.reason)
}
})
}
}
func TestNewsletterFixtureIsJunk(t *testing.T) {
msg, err := ParseMessage(9, fixture(t, "newsletter.eml"))
if err != nil {
t.Fatalf("parse: %v", err)
}
if !msg.Junk {
t.Fatal("a newsletter with List-Unsubscribe + Precedence: bulk must be junk")
}
// The reason is what gets logged, so it must never carry mail content.
if strings.Contains(msg.JunkReason, "@") || strings.Contains(msg.JunkReason, "Скидки") {
t.Errorf("junk reason leaks content: %q", msg.JunkReason)
}
}
+258
View File
@@ -0,0 +1,258 @@
// Package email is the reading half of the email reader (Vikunja #246,
// docs/plans/01-email-reader.md): a small IMAP client, a MIME-to-plaintext
// converter, and the junk filter that decides a message is not worth reading at
// all. Extraction lives in extract.go and writes nothing itself.
//
// Two constraints shape everything here, both from CLAUDE.md:
//
// - Mail is personal. Nothing in this package logs a body, a subject, or an
// address; callers get the text and decide. Mail text is never search input
// — no function here reaches the network except the IMAP connection itself.
// - Off unless configured. There is no default host, no default account, and
// no fallback that would make a mailbox get read because a field was empty.
//
// The IMAP subset is deliberately tiny (LOGIN, SELECT, UID SEARCH, UID FETCH
// with BODY.PEEK, LOGOUT). No IDLE: a poll every few minutes is what a task
// candidate needs, and IDLE would mean holding a connection and a credential
// open forever for latency nobody is waiting on.
package email
import (
"encoding/base64"
"fmt"
"io"
"mime"
"mime/multipart"
"mime/quotedprintable"
"net/mail"
"regexp"
"strings"
)
// MaxBodyBytes — how much of one message body is kept. A task hides in the
// first screenful; the rest is signature, quoted history and legal boilerplate,
// and it would only spend the resident model's 4096-token context.
const MaxBodyBytes = 4000
// Message — one mail, reduced to the fields extraction and review need.
//
// Raw is deliberately absent: once a message is parsed the original bytes are
// dropped, so no caller can accidentally log or forward the whole mail.
type Message struct {
UID uint32
From string
Subject string
Date string // as sent, unparsed — display only
Body string // plaintext, decoded, HTML-stripped, truncated
// Junk is set by the junk filter (see junk.go). A junk message is carried
// rather than dropped so the poller can count it and still mark it seen.
Junk bool
JunkReason string
}
// ParseMessage turns one RFC 5322 message into a Message.
//
// It never fails on a body it cannot understand: an unparsable or
// unsupported-charset body yields an empty Body and the headers still come
// through, because a subject line alone is often the whole task ("Счёт за
// интернет"). Only a message whose headers cannot be read at all is an error.
func ParseMessage(uid uint32, raw []byte) (Message, error) {
m, err := mail.ReadMessage(strings.NewReader(string(raw)))
if err != nil {
return Message{}, fmt.Errorf("email: parse message: %w", err)
}
msg := Message{
UID: uid,
From: decodeHeader(m.Header.Get("From")),
Subject: decodeHeader(m.Header.Get("Subject")),
Date: m.Header.Get("Date"),
}
msg.Junk, msg.JunkReason = classifyJunk(m.Header)
body, err := plaintextBody(m.Header.Get("Content-Type"), m.Header.Get("Content-Transfer-Encoding"), m.Body)
if err == nil {
msg.Body = truncate(collapse(body), MaxBodyBytes)
}
return msg, nil
}
// plaintextBody walks the MIME tree and returns the best plaintext it can.
//
// Preference order inside a multipart: text/plain first, text/html stripped
// only when there is no plain part. multipart/mixed attachments are skipped
// wholesale — an attachment is a file, not a sentence, and reading one would
// mean parsing arbitrary formats from the network.
func plaintextBody(contentType, encoding string, body io.Reader) (string, error) {
mediaType, params, err := mime.ParseMediaType(contentType)
if contentType == "" || err != nil {
// No Content-Type at all is legal and means text/plain; a broken one is
// treated the same rather than dropping the message.
mediaType, params = "text/plain", nil
}
switch {
case strings.HasPrefix(mediaType, "multipart/"):
boundary := params["boundary"]
if boundary == "" {
return "", fmt.Errorf("email: multipart without boundary")
}
return multipartText(multipart.NewReader(body, boundary))
case mediaType == "text/html":
raw, err := decodeBody(body, encoding, params["charset"])
if err != nil {
return "", err
}
return stripHTML(raw), nil
case mediaType == "text/plain":
return decodeBody(body, encoding, params["charset"])
default:
// A single-part non-text message (a bare PDF, say). No body, headers only.
return "", nil
}
}
// multipartText reads one multipart level, recursing into nested multiparts.
// Returns the plain part if any part yielded one, else the stripped HTML.
func multipartText(mr *multipart.Reader) (string, error) {
var plain, html string
for {
part, err := mr.NextPart()
if err == io.EOF {
break
}
if err != nil {
// A truncated multipart still gives up whatever came before it.
break
}
if part.FileName() != "" {
part.Close()
continue // attachment
}
ct := part.Header.Get("Content-Type")
mediaType, _, _ := mime.ParseMediaType(ct)
text, err := plaintextBody(ct, part.Header.Get("Content-Transfer-Encoding"), part)
part.Close()
if err != nil || strings.TrimSpace(text) == "" {
continue
}
if mediaType == "text/html" && !strings.HasPrefix(mediaType, "multipart/") {
if html == "" {
html = text
}
continue
}
if plain == "" {
plain = text
}
}
if strings.TrimSpace(plain) != "" {
return plain, nil
}
return html, nil
}
// decodeBody applies the transfer encoding, then the charset.
//
// Charset support is UTF-8 (and ASCII, its subset) only, on purpose: x/text's
// encoding tables are not vendored here, and guessing at windows-1251 bytes
// would feed the model mojibake it would happily extract a task from. An
// unsupported charset returns an error, which ParseMessage turns into an empty
// body — subject-only, which is honest.
func decodeBody(r io.Reader, encoding, charset string) (string, error) {
switch strings.ToLower(strings.TrimSpace(encoding)) {
case "quoted-printable":
r = quotedprintable.NewReader(r)
case "base64":
r = newBase64Reader(r)
}
b, err := io.ReadAll(io.LimitReader(r, 1<<20))
if err != nil && len(b) == 0 {
return "", fmt.Errorf("email: read body: %w", err)
}
switch cs := strings.ToLower(strings.TrimSpace(charset)); cs {
case "", "utf-8", "utf8", "us-ascii", "ascii":
return string(b), nil
default:
return "", fmt.Errorf("email: unsupported charset %q", cs)
}
}
// decodeHeader decodes RFC 2047 encoded words ("=?utf-8?B?...?="), which is how
// every Russian subject line arrives. Undecodable headers come back as-is
// rather than empty: a mangled subject is still a hint, and it is only ever
// shown to him as evidence.
func decodeHeader(v string) string {
dec := new(mime.WordDecoder)
out, err := dec.DecodeHeader(v)
if err != nil {
return collapse(v)
}
return collapse(out)
}
var (
scriptStyle = regexp.MustCompile(`(?is)<(script|style)\b[^>]*>.*?</\s*(script|style)\s*>`)
htmlBreak = regexp.MustCompile(`(?i)<\s*(br\s*/?|/p|/div|/tr|/li|/h[1-6])\s*>`)
htmlTag = regexp.MustCompile(`(?s)<[^>]*>`)
htmlComment = regexp.MustCompile(`(?s)<!--.*?-->`)
)
// stripHTML reduces an HTML part to text. A regex stripper, not a parser:
// x/net/html is not vendored, and the consumer is a model reading prose — a
// stray angle bracket costs nothing, whereas a new dependency for the privacy-
// sensitive path costs review.
func stripHTML(s string) string {
s = scriptStyle.ReplaceAllString(s, " ")
s = htmlComment.ReplaceAllString(s, " ")
s = htmlBreak.ReplaceAllString(s, "\n")
s = htmlTag.ReplaceAllString(s, " ")
return unescapeEntities(s)
}
var entities = strings.NewReplacer(
"&nbsp;", " ", "&amp;", "&", "&lt;", "<", "&gt;", ">",
"&quot;", `"`, "&#39;", "'", "&apos;", "'", "&mdash;", "—", "&ndash;", "",
)
func unescapeEntities(s string) string { return entities.Replace(s) }
// collapse squeezes runs of whitespace, keeping single newlines. Mail bodies
// arrive with hard-wrapped lines and blocks of blank space; the model does not
// need them and they are pure context budget.
func collapse(s string) string {
lines := strings.Split(strings.ReplaceAll(s, "\r\n", "\n"), "\n")
var out []string
blank := 0
for _, l := range lines {
l = strings.TrimSpace(strings.Join(strings.Fields(l), " "))
if l == "" {
blank++
if blank > 1 {
continue
}
out = append(out, "")
continue
}
blank = 0
out = append(out, l)
}
return strings.TrimSpace(strings.Join(out, "\n"))
}
// truncate cuts to n bytes on a rune boundary.
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
cut := s[:n]
for len(cut) > 0 && !isRuneStart(cut[len(cut)-1]) {
cut = cut[:len(cut)-1]
}
return strings.TrimSpace(cut) + "…"
}
func isRuneStart(b byte) bool { return b&0xC0 != 0x80 }
// newBase64Reader — base64.NewDecoder already skips the CRLFs mail bodies wrap
// with, so this is only a named seam for decodeBody to read cleanly.
func newBase64Reader(r io.Reader) io.Reader {
return base64.NewDecoder(base64.StdEncoding, r)
}
+111
View File
@@ -0,0 +1,111 @@
package email
import (
"os"
"path/filepath"
"strings"
"testing"
)
func fixture(t *testing.T, name string) []byte {
t.Helper()
b, err := os.ReadFile(filepath.Join("testdata", name))
if err != nil {
t.Fatalf("read fixture %s: %v", name, err)
}
return b
}
func TestParsePlainRussian(t *testing.T) {
msg, err := ParseMessage(7, fixture(t, "plain_ru.eml"))
if err != nil {
t.Fatalf("parse: %v", err)
}
if msg.UID != 7 {
t.Errorf("uid = %d, want 7", msg.UID)
}
if want := "Нужно закрыть задачу"; msg.Subject != want {
t.Errorf("subject = %q, want %q", msg.Subject, want)
}
if !strings.Contains(msg.From, "Антон") {
t.Errorf("from = %q, want the decoded display name", msg.From)
}
if !strings.Contains(msg.Body, "Надо отправить акт до пятницы.") {
t.Errorf("body = %q, want the quoted-printable text decoded", msg.Body)
}
if msg.Junk {
t.Errorf("a personal mail must not be junk (%s)", msg.JunkReason)
}
}
func TestParseHTMLOnlyIsStripped(t *testing.T) {
msg, err := ParseMessage(1, fixture(t, "html_only.eml"))
if err != nil {
t.Fatalf("parse: %v", err)
}
if strings.Contains(msg.Body, "<") || strings.Contains(msg.Body, "color:red") || strings.Contains(msg.Body, "x()") {
t.Errorf("body still has markup/script/style: %q", msg.Body)
}
for _, want := range []string{"Счёт за интернет: 700", "Оплатить до 5 августа."} {
if !strings.Contains(msg.Body, want) {
t.Errorf("body = %q, want it to contain %q", msg.Body, want)
}
}
// &nbsp; must have become a real space, not vanished into the number.
if strings.Contains(msg.Body, "&nbsp;") {
t.Errorf("entity left unescaped: %q", msg.Body)
}
}
func TestParsePrefersPlainAndSkipsAttachments(t *testing.T) {
msg, err := ParseMessage(2, fixture(t, "mixed_attachment.eml"))
if err != nil {
t.Fatalf("parse: %v", err)
}
if got := strings.TrimSpace(msg.Body); got != "Sign the contract before Monday." {
t.Errorf("body = %q, want the text/plain alternative only", got)
}
if strings.Contains(msg.Body, "PDF") {
t.Errorf("attachment bytes leaked into the body: %q", msg.Body)
}
}
// An unsupported charset must degrade to headers-only rather than to mojibake
// the model would then extract a task from.
func TestParseUnsupportedCharsetKeepsHeaders(t *testing.T) {
msg, err := ParseMessage(3, fixture(t, "cp1251.eml"))
if err != nil {
t.Fatalf("parse: %v", err)
}
if msg.Subject != "Legacy" {
t.Errorf("subject = %q, want Legacy", msg.Subject)
}
if msg.Body != "" {
t.Errorf("body = %q, want empty for an undecodable charset", msg.Body)
}
}
func TestParseTruncatesLongBody(t *testing.T) {
var b strings.Builder
b.WriteString("Subject: long\r\nContent-Type: text/plain; charset=utf-8\r\n\r\n")
for i := 0; i < 2000; i++ {
b.WriteString("длинная строка ")
}
msg, err := ParseMessage(4, []byte(b.String()))
if err != nil {
t.Fatalf("parse: %v", err)
}
if len(msg.Body) > MaxBodyBytes+8 {
t.Errorf("body kept %d bytes, want ≤ %d", len(msg.Body), MaxBodyBytes)
}
if !strings.HasSuffix(msg.Body, "…") {
t.Errorf("truncated body should be marked: %q", msg.Body[len(msg.Body)-20:])
}
}
func TestCollapseSqueezesBlankLines(t *testing.T) {
got := collapse(" a b \r\n\r\n\r\n\r\n c \r\n")
if got != "a b\n\nc" {
t.Errorf("collapse = %q, want %q", got, "a b\n\nc")
}
}
+7
View File
@@ -0,0 +1,7 @@
From: legacy@example.org
To: kami@example.org
Subject: Legacy
Date: Fri, 01 Aug 2026 05:00:00 +0400
Content-Type: text/plain; charset="windows-1251"
Ï
+13
View File
@@ -0,0 +1,13 @@
From: billing@isp.example
To: kami@example.org
Subject: =?utf-8?B?0KHRh9GR0YIg0LfQsCDQuNC90YLQtdGA0L3QtdGC?=
Date: Fri, 01 Aug 2026 08:00:00 +0400
MIME-Version: 1.0
Content-Type: multipart/alternative; boundary="B1"
--B1
Content-Type: text/html; charset="utf-8"
Content-Transfer-Encoding: base64
PGh0bWw+PGhlYWQ+PHN0eWxlPnB7Y29sb3I6cmVkfTwvc3R5bGU+PC9oZWFkPjxib2R5PjxwPtCh0YfRkdGCINC30LAg0LjQvdGC0LXRgNC90LXRgjogNzAwJm5ic3A74oK9PC9wPjxwPtCe0L/Qu9Cw0YLQuNGC0Ywg0LTQviA1INCw0LLQs9GD0YHRgtCwLjwvcD48c2NyaXB0PngoKTwvc2NyaXB0PjwvYm9keT48L2h0bWw+
--B1--
+26
View File
@@ -0,0 +1,26 @@
From: hr@work.example
To: kami@example.org
Subject: Contract
Date: Fri, 01 Aug 2026 07:00:00 +0400
MIME-Version: 1.0
Content-Type: multipart/mixed; boundary="M1"
--M1
Content-Type: multipart/alternative; boundary="A1"
--A1
Content-Type: text/plain; charset="utf-8"
Sign the contract before Monday.
--A1
Content-Type: text/html; charset="utf-8"
<p>Sign the contract before Monday.</p>
--A1--
--M1
Content-Type: application/pdf; name="contract.pdf"
Content-Disposition: attachment; filename="contract.pdf"
Content-Transfer-Encoding: base64
JVBERi0xLjQgbm90IHJlYWxseSBhIHBkZg==
--M1--
+9
View File
@@ -0,0 +1,9 @@
From: news@shop.example
To: kami@example.org
Subject: =?utf-8?B?0KHQutC40LTQutC4INGC0L7Qu9GM0LrQviDRgdC10LPQvtC00L3Rjw==?=
Date: Fri, 01 Aug 2026 06:00:00 +0400
List-Unsubscribe: <mailto:unsub@shop.example>
Precedence: bulk
Content-Type: text/plain; charset="utf-8"
Sale!
+14
View File
@@ -0,0 +1,14 @@
From: =?utf-8?B?0JDQvdGC0L7QvQ==?= <anton@example.org>
To: kami@example.org
Subject: =?utf-8?B?0J3Rg9C20L3QviDQt9Cw0LrRgNGL0YLRjCDQt9Cw0LTQsNGH0YM=?=
Date: Fri, 01 Aug 2026 09:12:00 +0400
Content-Type: text/plain; charset="utf-8"
Content-Transfer-Encoding: quoted-printable
Message-ID: <plain-ru@example.org>
=D0=9F=D1=80=D0=B8=D0=B2=D0=B5=D1=82! =D0=9D=D0=B0=D0=B4=D0=BE =D0=BE=D1=82=
=D0=BF=D1=80=D0=B0=D0=B2=D0=B8=D1=82=D1=8C =D0=B0=D0=BA=D1=82 =D0=B4=D0=BE =
=D0=BF=D1=8F=D1=82=D0=BD=D0=B8=D1=86=D1=8B.
--
Anton
+129
View File
@@ -0,0 +1,129 @@
package event
import (
"sync"
"time"
)
// Bus — the in-memory intake journal: a bounded ring of recent Events plus
// zero or more subscribers.
//
// Two properties are load-bearing, both about not changing production
// behaviour when nobody is watching:
//
// - A nil *Bus is a working no-op. Publish on nil returns immediately, so
// an intake path can call b.Publish(...) unconditionally and a daemon that
// never built a bus behaves exactly as it did before. This is what let
// eight callers adopt the envelope without a config flag each.
// - Publish never blocks on a subscriber and never propagates a panic from
// one. Intake is on the request path of POST /api/ambient and of every
// fact write; a slow or broken observer must not be able to stall or kill
// a write that already succeeded.
//
// The ring is bounded because it is memory that nothing prunes otherwise. Its
// contents are a window, not a record: the durable consequence of an event is
// the fact, note or task the intake path wrote.
type Bus struct {
mu sync.Mutex
ring []Event // len == cap once full; oldest at (next % cap)
next int
n int
subs []func(Event)
}
// DefaultCapacity — how many recent events a bus keeps. A busy day is a few
// hundred intake events (a feed poll is one per new item), so this is roughly
// "today and yesterday" at a few hundred KB.
const DefaultCapacity = 512
// NewBus returns a bus keeping the last capacity events. capacity <= 0 uses
// DefaultCapacity.
func NewBus(capacity int) *Bus {
if capacity <= 0 {
capacity = DefaultCapacity
}
return &Bus{ring: make([]Event, capacity)}
}
// Publish normalizes e, drops it if it is not Valid, appends it to the ring and
// hands it to every subscriber. Safe on a nil receiver and safe from any
// goroutine.
//
// now is passed in rather than read from the clock: the whole point of #284's
// replay is that no time.Now() sits inside a path a scenario drives.
func (b *Bus) Publish(e Event, now time.Time) {
if b == nil {
return
}
e = e.Normalize(now)
if !e.Valid() {
return
}
b.mu.Lock()
b.ring[b.next] = e
b.next = (b.next + 1) % len(b.ring)
if b.n < len(b.ring) {
b.n++
}
subs := make([]func(Event), len(b.subs))
copy(subs, b.subs)
b.mu.Unlock()
for _, fn := range subs {
notify(fn, e)
}
}
// notify calls one subscriber, swallowing a panic. A test double or a page
// renderer must not be able to take down a daemon from the intake path.
func notify(fn func(Event), e Event) {
defer func() { _ = recover() }()
fn(e)
}
// Subscribe registers fn to be called for every subsequent event, in publish
// order. There is no unsubscribe: subscribers are wired at startup and live as
// long as the daemon. Safe on a nil receiver (the subscription is dropped,
// which is the honest outcome when there is no bus to subscribe to).
func (b *Bus) Subscribe(fn func(Event)) {
if b == nil || fn == nil {
return
}
b.mu.Lock()
defer b.mu.Unlock()
b.subs = append(b.subs, fn)
}
// Recent returns up to limit events, newest first. limit <= 0 returns
// everything held. Safe on a nil receiver (returns nil).
func (b *Bus) Recent(limit int) []Event {
if b == nil {
return nil
}
b.mu.Lock()
defer b.mu.Unlock()
if b.n == 0 {
return nil
}
if limit <= 0 || limit > b.n {
limit = b.n
}
out := make([]Event, 0, limit)
// next points one past the newest; walk backwards.
for i := 0; i < limit; i++ {
idx := (b.next - 1 - i + len(b.ring)*2) % len(b.ring)
out = append(out, b.ring[idx])
}
return out
}
// Len reports how many events the ring currently holds. Safe on nil.
func (b *Bus) Len() int {
if b == nil {
return 0
}
b.mu.Lock()
defer b.mu.Unlock()
return b.n
}
+198
View File
@@ -0,0 +1,198 @@
// Package event is the unified intake envelope (Vikunja #283,
// 20-07-2026-BACKLOG.md item 1).
//
// # The problem it solves
//
// Things arrive at Maven from a lot of directions: a relayed Android
// notification (POST /api/ambient), a mail the reader extracted candidates
// from (ingest_mail), an RSS item, a changed page the crawler noticed, a
// zenmoney spend, a CalDAV event, a wg handshake that means he is home, a
// photo he sent, a meeting she was asked to record. Each of those grew its own
// shape, its own storage decision and its own log line. Nothing could answer
// "what came in today, from where" without reading eight packages.
//
// An Event is that answer: one flat, source-agnostic description of "something
// arrived". It is deliberately NOT a new storage layer and NOT a new transport.
// Every intake path keeps writing exactly what it wrote before — a fact, a
// note, a candidate task — and additionally describes what it did as an Event.
// The envelope is a VIEW over intake, not a replacement for it, which is why
// adopting it did not require touching eight callers.
//
// # What it is not
//
// - Not a command. An Event is a report of something that happened; nothing
// in Maven executes one. Digestion may read them; it may not be driven by
// an event alone, because "a thing arrived" is not "a thing must be said".
// - Not durable. The bus is a bounded in-memory ring. An event's durable
// consequence is the fact/note/task the intake path already wrote; the
// envelope is the recent-history window on top. A restart losing the ring
// loses nothing that mattered.
// - Not a secret store. Body carries what the intake path was already willing
// to log or store. Nothing puts a mail body, an IMAP password or a
// voiceprint in here, and callers must keep it that way.
package event
import (
"encoding/json"
"strings"
"time"
)
// Event — one thing that arrived, normalized.
//
// The field set is the one recorded in the backlog, and it is intentionally
// small: anything source-specific goes in Payload, so adding a source never
// widens the struct and never breaks a reader.
type Event struct {
// Source — provenance, in the facts vocabulary already used across the
// repo: "ambient:notif", "caldav:personal", "poll:zenmoney", "rss:<feed>",
// "crawl:<watch>", "email:<mailbox>", "infer:wg", "tap:voice". Same string
// the fact or note was written under, so an event and its row can be
// matched up by eye.
Source string `json:"source"`
// Kind — what sort of thing arrived, from the closed set below. This is the
// field digestion switches on; Source is for provenance and display.
Kind string `json:"kind"`
// EntityIDs — Nexus entity ids this event is about, when the intake path
// knew any. Usually empty: most intake happens before enrichment resolves a
// subject to an entity.
EntityIDs []string `json:"entity_ids,omitempty"`
// Title — one short line, safe to show on a page. For a fact it is the key,
// for a note the first line, for a task the task text.
Title string `json:"title"`
// Body — optional detail, already truncated by the caller.
Body string `json:"body,omitempty"`
// Priority — one of PriorityLow / PriorityNormal / PriorityHigh. It is a
// hint about attention, not a delivery instruction: nothing here decides
// whether Maven speaks. That stays with internal/loop and internal/delivery,
// where the severity/presence routing table lives.
Priority string `json:"priority"`
// OccurredAt — when the thing happened, NOT when Maven noticed it. A wg
// handshake carries the handshake instant; an RSS item carries its publish
// time. Intake paths already make this distinction when writing facts, and
// the envelope must not flatten it.
OccurredAt time.Time `json:"occurred_at"`
// Payload — source-specific extra, opaque here. Optional.
Payload json.RawMessage `json:"payload,omitempty"`
}
// Kinds. Closed set: a reader may switch on these exhaustively. A new intake
// path picks the closest existing kind before it adds one — the point of the
// envelope is that digestion has a small stable input.
const (
// KindFact — something was written to the facts table: a calendar read, a
// zenmoney window, a presence probe, a crawler watermark.
KindFact = "fact"
// KindNote — something was written to the notes table: an RSS item, a
// changed page, a meeting transcript, an image description.
KindNote = "note"
// KindTask — a candidate task was captured: the mail reader, the web form,
// the voice path.
KindTask = "task"
// KindMessage — an inbound message on a reach channel. Nothing produces
// this yet (telegram is send-only today); the kind exists so the bridge,
// when it lands, is a constructor and not a schema change.
KindMessage = "message"
// KindHealth — a service or probe reported its own state.
KindHealth = "health"
)
// Priorities.
const (
PriorityLow = "low"
PriorityNormal = "normal"
PriorityHigh = "high"
)
// TitleMaxRunes / BodyMaxRunes bound what an envelope carries. The ring is
// in memory and served to a web page; a 40 KB crawled article has no business
// in either. Cut on a rune boundary — most of this text is Russian and half a
// cyrillic letter is a broken line.
const (
TitleMaxRunes = 120
BodyMaxRunes = 400
)
// Normalize returns e with its fields put in range: whitespace collapsed out
// of Title, Title and Body truncated, an unknown or empty Priority forced to
// PriorityNormal, and a zero OccurredAt filled from now.
//
// It takes now as a parameter rather than reading the clock, so the whole
// package stays pure and the simulator (Vikunja #284) can replay intake against
// a scripted clock.
func (e Event) Normalize(now time.Time) Event {
e.Title = truncateRunes(strings.Join(strings.Fields(e.Title), " "), TitleMaxRunes)
e.Body = truncateRunes(strings.TrimSpace(e.Body), BodyMaxRunes)
if !validPriority(e.Priority) {
e.Priority = PriorityNormal
}
if e.Kind == "" {
e.Kind = KindFact
}
if e.OccurredAt.IsZero() {
e.OccurredAt = now
}
return e
}
// Valid reports whether e carries the minimum a reader can rely on: a source,
// a known kind, a title and a time. The bus drops anything that fails — an
// envelope with no provenance is worse than no envelope, because it looks like
// evidence.
func (e Event) Valid() bool {
return e.Source != "" && validKind(e.Kind) && e.Title != "" && !e.OccurredAt.IsZero()
}
func validKind(k string) bool {
switch k {
case KindFact, KindNote, KindTask, KindMessage, KindHealth:
return true
}
return false
}
func validPriority(p string) bool {
switch p {
case PriorityLow, PriorityNormal, PriorityHigh:
return true
}
return false
}
// truncateRunes cuts s to n runes, marking the cut.
func truncateRunes(s string, n int) string {
r := []rune(s)
if len(r) <= n {
return s
}
return string(r[:n]) + "…"
}
// SourceKind guesses the Kind for a source string when the caller has not said
// otherwise. It exists so the one intake decorator in cmd/mavend does not need
// a switch per writer: the source prefix already tells you what arrived.
//
// Unknown prefixes get fallback, which is what the caller was going to write
// anyway (a WriteFact call knows it is a fact).
func SourceKind(source, fallback string) string {
switch {
case strings.HasPrefix(source, "rss:"), strings.HasPrefix(source, "crawl:"):
return KindNote
case strings.HasPrefix(source, "email:"):
return KindTask
case strings.HasPrefix(source, "probe:"), strings.HasPrefix(source, "health:"):
return KindHealth
}
return fallback
}
+185
View File
@@ -0,0 +1,185 @@
package event
import (
"strings"
"sync"
"testing"
"time"
)
var testNow = time.Date(2026, 8, 1, 9, 30, 0, 0, time.UTC)
func TestNormalizeFillsDefaults(t *testing.T) {
got := Event{Source: "poll:zenmoney", Title: " spent today "}.Normalize(testNow)
if got.Title != "spent today" {
t.Errorf("title = %q, want collapsed whitespace", got.Title)
}
if got.Priority != PriorityNormal {
t.Errorf("priority = %q, want %q", got.Priority, PriorityNormal)
}
if got.Kind != KindFact {
t.Errorf("kind = %q, want %q", got.Kind, KindFact)
}
if !got.OccurredAt.Equal(testNow) {
t.Errorf("occurred_at = %v, want %v", got.OccurredAt, testNow)
}
}
func TestNormalizeKeepsRealOccurredAt(t *testing.T) {
// A wg handshake carries the handshake instant, not "now". Flattening that
// would make every intake look like it happened at notice time.
real := testNow.Add(-3 * time.Hour)
got := Event{Source: "infer:wg", Title: "wg_handshake", OccurredAt: real}.Normalize(testNow)
if !got.OccurredAt.Equal(real) {
t.Errorf("occurred_at = %v, want the supplied %v", got.OccurredAt, real)
}
}
func TestNormalizeTruncatesOnRuneBoundary(t *testing.T) {
long := strings.Repeat("я", TitleMaxRunes+50)
got := Event{Source: "rss:x", Title: long}.Normalize(testNow)
r := []rune(got.Title)
if len(r) != TitleMaxRunes+1 { // +1 for the ellipsis marker
t.Fatalf("title runes = %d, want %d", len(r), TitleMaxRunes+1)
}
if r[len(r)-1] != '…' {
t.Errorf("truncated title does not mark the cut: %q", string(r[len(r)-3:]))
}
for _, c := range r[:TitleMaxRunes] {
if c != 'я' {
t.Fatalf("truncation broke a rune: got %q", c)
}
}
}
func TestNormalizeRejectsUnknownPriority(t *testing.T) {
got := Event{Source: "s", Title: "t", Priority: "URGENT!!"}.Normalize(testNow)
if got.Priority != PriorityNormal {
t.Errorf("priority = %q, want %q", got.Priority, PriorityNormal)
}
}
func TestValid(t *testing.T) {
base := Event{Source: "rss:tech", Kind: KindNote, Title: "заголовок", OccurredAt: testNow}
if !base.Valid() {
t.Fatal("well-formed event reported invalid")
}
for name, mut := range map[string]func(Event) Event{
"no source": func(e Event) Event { e.Source = ""; return e },
"no title": func(e Event) Event { e.Title = ""; return e },
"no time": func(e Event) Event { e.OccurredAt = time.Time{}; return e },
"bad kind": func(e Event) Event { e.Kind = "whatever"; return e },
} {
if mut(base).Valid() {
t.Errorf("%s: reported valid", name)
}
}
}
func TestSourceKind(t *testing.T) {
cases := map[string]string{
"rss:tech": KindNote,
"crawl:kernel": KindNote,
"email:inbox": KindTask,
"probe:netdata": KindHealth,
"ambient:notif": KindFact,
"tap:voice": KindFact,
}
for src, want := range cases {
if got := SourceKind(src, KindFact); got != want {
t.Errorf("SourceKind(%q) = %q, want %q", src, got, want)
}
}
}
func TestBusNilIsANoOp(t *testing.T) {
// The whole adoption story depends on this: an intake path calls Publish
// unconditionally, and a daemon with no bus behaves as it did before.
var b *Bus
b.Publish(Event{Source: "s", Kind: KindFact, Title: "t"}, testNow)
b.Subscribe(func(Event) { t.Error("nil bus delivered to a subscriber") })
if got := b.Recent(10); got != nil {
t.Errorf("Recent on nil bus = %v, want nil", got)
}
if got := b.Len(); got != 0 {
t.Errorf("Len on nil bus = %d, want 0", got)
}
}
func TestBusRecentIsNewestFirst(t *testing.T) {
b := NewBus(8)
for _, title := range []string{"one", "two", "three"} {
b.Publish(Event{Source: "rss:t", Kind: KindNote, Title: title}, testNow)
}
got := b.Recent(0)
if len(got) != 3 {
t.Fatalf("len = %d, want 3", len(got))
}
want := []string{"three", "two", "one"}
for i, w := range want {
if got[i].Title != w {
t.Errorf("Recent()[%d] = %q, want %q", i, got[i].Title, w)
}
}
if lim := b.Recent(2); len(lim) != 2 || lim[0].Title != "three" {
t.Errorf("Recent(2) = %v, want the two newest", lim)
}
}
func TestBusRingEvicts(t *testing.T) {
b := NewBus(3)
for _, title := range []string{"a", "b", "c", "d", "e"} {
b.Publish(Event{Source: "s", Kind: KindFact, Title: title}, testNow)
}
if b.Len() != 3 {
t.Fatalf("Len = %d, want the capacity 3", b.Len())
}
got := b.Recent(0)
want := []string{"e", "d", "c"}
for i, w := range want {
if got[i].Title != w {
t.Errorf("Recent()[%d] = %q, want %q", i, got[i].Title, w)
}
}
}
func TestBusDropsInvalid(t *testing.T) {
b := NewBus(4)
b.Publish(Event{Kind: KindFact, Title: "no source"}, testNow)
b.Publish(Event{Source: "s", Kind: KindFact}, testNow)
if b.Len() != 0 {
t.Errorf("Len = %d, want 0 — an envelope with no provenance must not be kept", b.Len())
}
}
func TestBusSubscriberPanicDoesNotBreakIntake(t *testing.T) {
b := NewBus(4)
var seen int
b.Subscribe(func(Event) { panic("observer is broken") })
b.Subscribe(func(Event) { seen++ })
b.Publish(Event{Source: "s", Kind: KindFact, Title: "t"}, testNow)
if seen != 1 {
t.Errorf("healthy subscriber called %d times, want 1", seen)
}
if b.Len() != 1 {
t.Errorf("event not recorded despite a panicking subscriber")
}
}
func TestBusConcurrentPublish(t *testing.T) {
b := NewBus(256)
var wg sync.WaitGroup
for i := 0; i < 16; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for j := 0; j < 10; j++ {
b.Publish(Event{Source: "s", Kind: KindFact, Title: "t"}, testNow)
}
}()
}
wg.Wait()
if b.Len() != 160 {
t.Errorf("Len = %d, want 160", b.Len())
}
}
+309 -6
View File
@@ -4,6 +4,8 @@ import (
"context"
"errors"
"time"
"github.com/kami/maven/internal/audio"
)
// DTOs — wire-level data. Decoupled from internal/store so the protocol is
@@ -138,6 +140,256 @@ type CaptureTaskResp struct {
Created bool `json:"created"`
}
// IngestMailReq — one message a mail reader has fetched, handed to core for
// extraction (Vikunja #246).
//
// The mail reader (cmd/mavmaild) holds the IMAP credential and core never sees
// it, the same split mavpoll uses for the zenmoney token. What crosses this
// boundary is only the message text, because extraction runs on the resident
// model and llama-server lives inside core's process.
//
// Body is already plaintext and truncated by internal/email; core does not
// re-parse MIME and never stores the body. Junk means the reader's header
// filter already classified the message as bulk — core is told rather than
// asked, so a junk message can be counted without a model call.
//
// This method is available only when core has an email block configured AND a
// llama-server phraser; otherwise it answers ErrUnknownMethod, which is what
// "off unless configured" looks like at the wire.
type IngestMailReq struct {
Mailbox string `json:"mailbox"`
UID uint32 `json:"uid"`
From string `json:"from,omitempty"`
Subject string `json:"subject,omitempty"`
Date string `json:"date,omitempty"`
Body string `json:"body,omitempty"`
Junk bool `json:"junk,omitempty"`
}
// IngestMailResp — what core did with the message. TaskIDs are the rows
// CaptureTask returned; Created counts the ones that were new (a re-read
// mailbox dedupes to Created=0). Skipped is set when nothing was asked of the
// model at all — junk, or an empty message.
//
// Nothing here echoes the mail back. The reader logs counts.
type IngestMailResp struct {
TaskIDs []int64 `json:"task_ids,omitempty"`
Created int `json:"created"`
Skipped bool `json:"skipped,omitempty"`
}
// DescribeImageReq — one image handed to core to look at (Vikunja #252).
//
// Data is the raw image file as received (png / jpeg / gif). Core sniffs it and
// refuses anything else; a declared content type is not part of this request
// because the sender's claim about its own bytes is not evidence. Base64 on the
// wire via the usual JSON marshal of []byte.
//
// Question is what he asked about the picture ("что тут написано?"). Empty ⇒
// core uses its configured default prompt.
//
// Source is provenance recorded on the stored blob: "telegram", "web:upload".
//
// Exactly one of Data or ID is set. ID re-describes an image core already has —
// a different question, or the first attempt that succeeds after a vision model
// finally lands on disk.
//
// The method exists only when core has both a media store and an enabled vision
// block; otherwise it answers ErrUnknownMethod, which is what "off unless
// configured" looks like at the wire. A surface cannot make Maven look at
// pictures by merely sending one.
type DescribeImageReq struct {
Data []byte `json:"data,omitempty"`
ID string `json:"id,omitempty"`
Source string `json:"source,omitempty"`
Question string `json:"question,omitempty"`
// SaveNote — also write the description as a note (source
// "media:image:<id-prefix>") so it is recallable later. Default false: a
// glance at a screenshot is not automatically a memory.
SaveNote bool `json:"save_note,omitempty"`
}
// DescribeImageResp — what she saw. ID is the stored blob's content address, and
// it is set even when Description is empty because the description failed: the
// bytes are on disk and the same id can be retried. NoteID is non-zero only when
// SaveNote was set and the write succeeded.
//
// The image itself is never echoed back.
type DescribeImageResp struct {
ID string `json:"id"`
Description string `json:"description,omitempty"`
Width int `json:"width,omitempty"`
Height int `json:"height,omitempty"`
NoteID int64 `json:"note_id,omitempty"`
}
// CaptureStartReq — begin recording a meeting (Vikunja #253).
//
// Label is what the meeting is called ("встреча с подрядчиком"); it goes into
// the summary note so the note is findable later. Empty is allowed.
//
// There is no "auto", no keyword and no schedule in this request, and there will
// not be: the only way audio enters the recorder is a client that was told to
// start, appending frames it was told to append. All four capture methods answer
// ErrUnknownMethod unless the operator enabled a capture block, so a surface
// cannot start a recording by asking nicely.
type CaptureStartReq struct {
Label string `json:"label,omitempty"`
}
// CaptureStartResp — the session that opened. MaxSeconds is the hard cap after
// which it stops itself; the caller tells him, so a forgotten recording is his
// own informed choice rather than a surprise.
type CaptureStartResp struct {
Label string `json:"label,omitempty"`
Started time.Time `json:"started"`
MaxSeconds int `json:"max_seconds"`
}
// CaptureAppendReq — one chunk of audio for the running session. Refused with
// "nothing is being recorded" when no session is open, which is the guard that
// makes an ambient path impossible: audio arriving at an idle core is dropped on
// the floor, not buffered "just in case".
type CaptureAppendReq struct {
Audio audio.Audio `json:"audio"`
}
// CaptureAppendResp — how much has been collected, so a client can show a timer
// and notice the cap coming. Expired means the session hit its limit and closed;
// stop sending and call capture_stop, the audio so far is kept.
type CaptureAppendResp struct {
Seconds float64 `json:"seconds"`
Expired bool `json:"expired,omitempty"`
}
// CaptureStopReq — end the running session.
//
// Discard throws the recording away without transcribing, storing or
// summarising anything. This is what "забудь, не записывай" maps to, and it is a
// flag rather than a separate method so the client that says "stop" and the
// client that says "stop and forget" take the same path to the same session.
type CaptureStopReq struct {
Discard bool `json:"discard,omitempty"`
}
// CaptureStopResp — the finished capture. BlobID is the stored WAV, kept under
// media.retention like any other blob and pruned with it.
//
// A response with a Transcript and an empty Summary is a degraded success: the
// words exist, only the model failed. A response with a BlobID and neither is
// the audio surviving a transcription failure — the same id can be run again.
// Discarded is true when nothing was kept.
type CaptureStopResp struct {
BlobID string `json:"blob_id,omitempty"`
Label string `json:"label,omitempty"`
Started time.Time `json:"started,omitempty"`
Seconds float64 `json:"seconds,omitempty"`
Transcript string `json:"transcript,omitempty"`
Summary string `json:"summary,omitempty"`
Chunks int `json:"chunks,omitempty"`
NoteID int64 `json:"note_id,omitempty"`
Discarded bool `json:"discarded,omitempty"`
}
// CaptureStatusResp — what "что ты записываешь?" needs, and what /dash shows.
// Running=false with everything else empty is the normal state.
type CaptureStatusResp struct {
Running bool `json:"running"`
Label string `json:"label,omitempty"`
Started time.Time `json:"started,omitempty"`
Seconds float64 `json:"seconds,omitempty"`
Bytes int `json:"bytes,omitempty"`
}
// EnrollSpeakerReq — register a voice (Vikunja #255).
//
// Samples are separate utterances recorded deliberately for this purpose, not
// audio harvested from ordinary turns. internal/speaker requires several of
// them totalling enough seconds, and refuses one long clip: a profile built
// from a single sentence encodes that sentence as much as the person.
//
// There is no "enrol whoever just spoke" request shape, and that omission is
// the point. Taking a biometric of a guest because they walked past the
// microphone is not something a wire protocol should make easy.
type EnrollSpeakerReq struct {
ID string `json:"id"`
Name string `json:"name,omitempty"`
Samples []audio.Audio `json:"samples"`
}
// Speaker — one enrolled voice as a surface sees it. The voiceprint itself is
// never sent: a listing says who is enrolled, it does not hand out the
// biometric.
type Speaker struct {
ID string `json:"id"`
Name string `json:"name"`
Enrolled time.Time `json:"enrolled"`
Samples int `json:"samples"`
}
// EnrollSpeakerResp — the profile that was written.
type EnrollSpeakerResp struct {
Speaker Speaker `json:"speaker"`
}
// ListSpeakersResp — who is enrolled, sorted by id. Enabled is false when no
// embedding model is wired, which is this box's state: the profiles can be
// listed and deleted, nothing can be recognised.
type ListSpeakersResp struct {
Speakers []Speaker `json:"speakers"`
Enabled bool `json:"enabled"`
}
// ForgetSpeakerReq — delete one voiceprint. This is the request that must
// always work; a biometric someone asked to be rid of has to actually go.
type ForgetSpeakerReq struct {
ID string `json:"id"`
}
// SwapModelReq — load another resident model without restarting the daemon
// (Vikunja #250). ModelPath must be one of the paths in phraser.swap_models;
// anything else is ErrForbidden, and an unconfigured allowlist makes the whole
// method ErrUnknownMethod.
//
// NGpuLayers and NCtx are zero for "keep what is loaded now", which is the
// normal case — the same laptop iGPU, a different gguf.
//
// This is an owner action. It is AuthStepUp in the authority table, it is not on
// CoreAPI, and no act, intent or timer can reach it: swapping the model is not
// something Maven does to herself.
type SwapModelReq struct {
ModelPath string `json:"model_path"`
NGpuLayers int `json:"n_gpu_layers,omitempty"`
NCtx int `json:"n_ctx,omitempty"`
}
// SwapModelResp — what the daemon ended up serving. Model is the identity the
// new llama-server reported for itself, not an echo of the request: if the file
// was not the model the operator thought it was, this is where it shows.
//
// RolledBack is true when the requested model failed to load or would not answer
// and the previous one was put back. In that case the call also returns an error
// — the swap did not happen — and Model names the model still serving.
type SwapModelResp struct {
Model string `json:"model"`
ModelPath string `json:"model_path"`
BaseURL string `json:"base_url"`
RolledBack bool `json:"rolled_back,omitempty"`
TookMs int64 `json:"took_ms"`
}
// ModelStatusResp — which model is resident and which ones may be swapped in.
// Read-only; the authed page renders it. Swappable is the configured allowlist,
// so an empty list means the capability is off.
type ModelStatusResp struct {
Model string `json:"model"`
ModelPath string `json:"model_path"`
BaseURL string `json:"base_url"`
NGpuLayers int `json:"n_gpu_layers"`
NCtx int `json:"n_ctx"`
Swappable []string `json:"swappable,omitempty"`
}
type listTasksReq struct {
Status string `json:"status"` // "" all | "live" | candidate|open|done|dropped
}
@@ -237,6 +489,19 @@ type Tool struct {
Updated time.Time `json:"updated"`
}
// MCPServerStatus — one configured MCP server, as the web surface sees it.
// Target is the command or url; Tools is how many tools discovery kept after
// allow_tools / max_tools, not how many the server offers.
type MCPServerStatus struct {
Name string `json:"name"`
Transport string `json:"transport"` // "stdio" (a local subprocess) or "http"
Target string `json:"target"`
Connected bool `json:"connected"`
Server string `json:"server,omitempty"` // the server's own name + version
Tools int `json:"tools"`
Err string `json:"err,omitempty"`
}
// chatReq / chatResp — text chat round-trip for the IPC Chat method.
type chatReq struct {
Text string `json:"text"`
@@ -375,6 +640,14 @@ type CoreAPI interface {
// TickTrace.
MorningStatus(ctx context.Context) ([]MorningRoutineStatus, error)
// MCPServers reports the configured MCP servers and their health
// (Vikunja #251). Read-only introspection for /tools — there is no
// "call this tool" method on purpose: an MCP tool runs through the same
// allowlist, confirm turn and act path as any other tool, and a second
// mutation path would be a second thing to get wrong. Empty when the
// mcp config block is absent, which is the default.
MCPServers(ctx context.Context) ([]MCPServerStatus, error)
// DayPlan returns today's ordered plan — calendar events, pending
// reminders and any morning checklist still outstanding (see
// internal/morning.BuildPlan) — plus the spoken RU rendering of it.
@@ -387,6 +660,31 @@ type CoreAPI interface {
// (router → dialogue → action → replier) and returns the reply text.
// No audio or stt/tts — for text channels (mavweb, telegram).
Chat(ctx context.Context, text string) (string, error)
// RecentEvents returns the daemon's unified intake journal, newest first
// (Vikunja #283) — one envelope per thing that arrived, whatever direction
// it came from: a relayed notification, a mail candidate, a feed item, a
// changed page, a spend, a presence probe.
//
// Read-only and daemon-cached, the same shape as TickTrace and DayPlan:
// the store adapter returns an error, because the journal is a bounded
// in-memory ring and not a table. Its contents are a window over intake,
// never the durable record — that is still the fact, note or task the
// intake path wrote.
RecentEvents(ctx context.Context, n int) ([]IntakeEvent, error)
}
// IntakeEvent — one entry of the unified intake journal on the wire. Mirrors
// event.Event field for field; the ipc package does not import internal/event
// so the wire shape stays independent of the in-process type.
type IntakeEvent struct {
Source string `json:"source"`
Kind string `json:"kind"`
EntityIDs []string `json:"entity_ids,omitempty"`
Title string `json:"title"`
Body string `json:"body,omitempty"`
Priority string `json:"priority"`
OccurredAt time.Time `json:"occurred_at"`
}
// --- Rule trace / explanation DTOs ---
@@ -456,17 +754,22 @@ type DayPlan struct {
Spoken string `json:"spoken"`
}
// storeEncryptionKeyReq — passkey credential public key for wrapping the store
// storeEncryptionKeyReq — the passkey-derived secret used to wrap the store
// encryption key at enrollment time. Called by mavweb after RegisterFinish.
//
// Secret is the 32-byte WebAuthn PRF output, NOT the credential public key.
// The field used to carry the public key and that was the bug: a public key
// sits in passkeys.json next to the wrapped blob, so the blob protected
// nothing. See internal/webauthn/keywrap.go.
type storeEncryptionKeyReq struct {
PublicKey []byte `json:"public_key"`
Secret []byte `json:"secret"`
}
// unlockReq — passkey credential public key for unwrapping the store
// encryption key at cold-start. mavend reads the wrapped blob from its own
// configured path; the public key is the other half needed for unwrapping.
// unlockReq — the passkey-derived secret for unwrapping the store encryption
// key at cold-start. mavend reads the wrapped blob from its own configured
// path; this is the other half. Same PRF-output contract as above.
type unlockReq struct {
PublicKey []byte `json:"public_key"`
Secret []byte `json:"secret"`
}
// ErrToolNotFound — no tool row with this name (re-exported store sentinel for

Some files were not shown because too many files have changed in this diff Show More