f44abcc526ef3d2e2ef7d3ec089fcf2cea197e4e
1105 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
da62a2f25e |
mcp: pin what a tool was when it was approved
An allowlist row stores cmd ["mcp", server, tool]. That is a late-bound reference to a name the far end owns, so the row pins nothing about behaviour: a server could redefine an enabled read-only list_tasks into something that writes, and Maven would keep calling it with no confirm turn and no second approval. Discovery now stores a fingerprint of the declared shape, name, description, input schema and readOnlyHint, and compares it on every refresh. A mismatch drops the row back to proposed and, if it stopped claiming read-only, marks it destructive. destructive is only ever raised. A row predating the column adopts its fingerprint silently, because an upgrade is not a redefinition. Nothing retracted a proposal either, so a tool a connected server no longer offers stayed enabled and failed at call time with an internal string. Those rows are withdrawn, with provenance saying why, and only for servers that are actually connected so a restart does not disarm what he approved. Argument binding rested on readOnlyHint, which the same server writes. A server advertising delete_project as read-only got an unconfirmed argument-carrying call. Binding now also requires the tool be named in allow_tools, something local, and refuses a required property the schema never describes rather than guessing it is a string. wireMCP dialled synchronously from run, and on the passkey path from inside the unlock handler, so one black-holed endpoint delayed boot and the answer to an unlock. The first dial happens on the refresh goroutine under the daemon context. Two servers whose names flatten to one local allowlist name no longer share a row. Found in review of #71. |
||
|
|
52f56947bb |
tool: separate a tool that is off from a backend that is down
An enabled MCP or smarthome row with no backend returned ErrNotEnabled, and actionAct reads ErrNotEnabled as "this is unknown, draft a proposal". So a tool Kami had already approved, whose server happened to be restarting, produced a second proposal row and an answer saying the tool needs approval. The right answer is that the server is down. ErrNotConnected carries that, and the act path maps it, ErrNoServer and ErrToolGone to replies that say which of the three happened. Found in review of #71. |
||
|
|
5e0417306b |
mcp: guard the connection, not just the first dial
Refresh called alive() with the manager lock held, so a slow health check blocked every other server. It now snapshots the candidates and asks outside the lock. A server that cannot be dialled was retried every minute forever, which for a misconfigured stdio block means re-exec'ing a process 1440 times a day. Dials now back off from one minute to thirty. An allow_private fetcher followed redirects. A LAN MCP endpoint could answer a POST with a redirect to 169.254.169.254 and the guard would go there, because allow_private is what turns the address check off. Redirects are refused outright on that door. The tool catalogue was trimmed by taking the first max_tools entries of whatever order the server sent, so the server chose which of its tools Maven proposed. Over the cap without allow_tools now contributes nothing: refusing is honest, silently keeping the server's pick is not. Descriptions are server-written text that lands in the router prompt and on /tools, so they are capped too. A server block with enabled false was skipped by validation, so a typo in a block written dark surfaced only on the day it was switched on. All blocks are shape-checked now. Configured static headers carry the bearer token a real remote server needs, and host_interval bounds how fast one endpoint is polled. Found in review of #70. |
||
|
|
87d03cf8c6 |
mcp: bound and abandon transport reads
The stdio reader ran inline under the transport lock, and bufio never observes a context. A server that accepted a request and then wrote nothing held that lock forever. alive() takes the same lock and Refresh calls alive() while holding the manager lock, so one mute python server wedged Tools, Status and every Call, including turns that touch no MCP tool at all. The read now runs on its own goroutine feeding a channel, the call selects on the context, and a call that gives up drops the connection so the manager re-dials. The frame bound was measured after the line had been assembled, which is not a bound. A server emitting 500 MB with no newline had all 500 MB in mavend before the check could reject it, which on the deploy target is an OOM kill of the core daemon. The scanner's own buffer limit enforces it now. The HTTP transport never checked the response id. A server request sent mid-stream, sampling/createMessage or roots/list, unmarshalled into a response with neither result nor error, so the call reported success with an empty string. The act was logged as done and the tool never ran. The id must match and the frame must carry a result or an error. Found in review of #70. |
||
|
|
1c94df76b7 |
mavcaldav: reconcile the render collection on startup
Withdrawal read published, which is in-memory, so the second loop only ever withdrew reminders this process had published. Fire a reminder, restart mavcaldav, and its event stayed in the collection forever with nothing left to revisit it. "Losing it costs nothing, the next tick rebuilds it" holds for events that should be there and not for the ones that should not. The first tick now PROPFINDs the collection and reconciles what it finds against what is pending. Only hrefs carrying ReminderUIDPrefix are read back, so the pass can never propose deleting a file maven did not create. A failed read is retried on the next tick rather than skipped for the life of the process. Two smaller things from the same review. checkRenderTarget takes the whole read set, so a second calendar to read cannot quietly fall outside the guarantee the package comment makes. writeIfChanged loses its confidence parameter, which every caller passed 1.0 and nothing read. Found in review of #56. |
||
|
|
9e383eb751 |
event: order the journal by notice time, and keep it to what arrived
The ring is insertion-ordered and the page called itself newest first while printing OccurredAt, which is when the thing happened. A cold feed read publishes a week of items in feed order and the ambient relay stamps a 09:00 notification with an 18:00 meeting, so the timestamp column ran forwards and backwards on the same page. Events now carry NoticedAt, filled by the bus and not by the caller, and the page sorts and labels by it while still showing when the thing itself happened. Four writers on that page had not arrived from anywhere: the feed watermark, the crawl hash, the praxis trace of an act she performed and a quiet-hours toggle he pressed. On a cold start with a few feeds they could evict real intake out of a 512-entry ring. The decorator now skips Maven's own bookkeeping. Priority was the only surviving trace of confidence, and it inverts: a relayed meeting at 0.6 read as low while an rss watermark at 1.0 read as normal. The fact's own kind, its confidence and the id it voids now travel in Payload, which was unused. A retraction is marked as one and scored low, instead of publishing an envelope indistinguishable from a fresh reading of the same key. Smaller: SourceKind no longer maps every email source to a task, so a future fact under an email prefix is not journalled as one; newEventBus is quiet when it is handed no config at all; and morningTmpl has its own doc comment back. Found in review of #78. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TrVSBKe3RFDF4fGYKWYQnX |
||
|
|
8c6332f95c |
mavmaild: own its state volume, retire aged-out UIDs, stop restart-looping
The commented compose service mounted dbdata, the encrypted database volume, read-write, for one JSON file of UIDs. The header of that same file says only mavend holds the key and the db volume, and the whole argument for a separate reader is that a compromise on either side does not reach the other. It gets its own volume now, at its own path, so neither can be restored from a backup of the other. The high-water mark only advances through a contiguous run, and a failed ingest is deliberately not marked. One message that never ingested therefore pinned the mark forever: after the lookback window passed it could never be fetched again, so the gap never closed, every UID above it stayed in the explicit set, and save rewrote all of them every poll. FetchSince now reports the SEARCH window and the poller retires everything below it, since a UID that can no longer be searched for can never be read. On ErrUnknownMethod the daemon logged "stopping" and then exited at the next tick with status 0. The compose service inherits restart: unless-stopped, which restarts a clean exit, so the real behaviour was a loop of four IMAP logins an hour against a mailbox core would not accept anything from. It now stays up and polls nothing. The reader also sends the Junk verdict instead of counting bulk locally, which is what the wire doc says it does. The verdict carries no mail content, since nothing on the other side will read it. RunWith is gone, so the tests fake the read rather than the transport. Found in review of #65. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TrVSBKe3RFDF4fGYKWYQnX |
||
|
|
bddf52d1ee |
router: refuse a plan question about a day that is not today
IsDayPlanQuery only rejected the сегодня family, so "какие планы на понедельник?" carried no other-day token, did carry "планы", and the plan claimed it ahead of the calendar listing and recited today under today's date. Weekday names, week, weekend and month join the refusal list. This is a refusal and not a feature: it stands until the plan can build a day other than the clock's own. isRestOfDayQuery also lived in cmd/mavend and matched by substring while IsDayPlanQuery tokenized, so the two predicates deciding one utterance could disagree, and "проверь nextcloud" read as a request for the rest of the day. It moves to the router and tokenizes. Found in review of #58. |
||
|
|
b3c2fad4ec |
morning: recite the day the store actually holds
Four defects in the plan, all of them in what it reads or how it prints it. The checklist line was keyed on Status.Active, which Evaluate reports only inside the window, so a morning routine skipped and asked about at 14:00 said nothing. Outstanding answers the question the plan asks, "what did today still not get done", and the line stays placed at the nudge time so it sorts to the top of the day. Nothing before the window opens counts, so 06:00 is not a complaint. The event text kept the "@ 14:00-14:30" tail FactValue writes, next to a line that prints the hour itself, so every event said its time twice. Reminders came off ListReminders, which orders by creation, so the 500 row cap dropped a reminder stated long ago for today and kept one stated this morning for next year. PendingReminders bounds by fire time instead. The pending filter used a string literal, one typo from matching nothing. After now marks the plan it trimmed. "что дальше?" past the last item answered "на 03.08.2026 ничего не запланировано", which denies a day he just lived through. The surface the plan belongs on is still open, tracked as Vikunja #431; the comment in actions_query.go points at it. Found in review of #58. |
||
|
|
d62ba093f5 |
smarthome: keep the devices under the cap, and stop asserting what the house did not do
States sorted every entity by id and cut at MaxEntities. Entity ids sort by domain prefix, so binary_sensor came first and forty slots went to connectivity and update-available rows: propose found nothing controllable, and homeSummary, reading the same list, said everything was off with the lights on. The cap stays, because a tool name the 1.7B half-remembers is a wrong act. What changes is which forty. Controllable domains are taken first and round-robin, so every switch and light is in before any sensor. CallService reported done for a call that changed nothing. Home Assistant answers a service call with the states it changed, and a removed entity or an offline integration gets 200 and an empty array. That is the one place Maven asserts something about the physical world, so an empty array is now ErrUnknownEntity. The confirm turn on a house row was a column, not an invariant. The proposal is destructive, but /tools writes the checkbox through on enable, so unticking it once made an unlock row that ran on first hearing. Exec now demands the second turn for any smarthome row whatever the column says, and lock is out of the default domain set so a bare block does not propose an unlock for every door. wireSmartHome enumerated the house synchronously, inside wireVoice, before the socket was serving and inside the unlock handler. A box that black-holes the connection held the daemon's start for the per-call timeout. The first propose moved onto the ticker goroutine. Smaller: an unreachable lamp is counted apart from an off one, a truncated on-list says how many it left out, refresh has a floor of a minute, and the http url is documented as a deliberate wg-only choice. Found in review of #80. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TrVSBKe3RFDF4fGYKWYQnX |
||
|
|
c21d8fdcee |
router: let a habit question outrank the day plan, and know the weekend
IsDayPlanQuery fires on the token "планы" and its other-day list does not know weekday names, so "какие у меня обычно планы по вторникам?" was claimed by the day plan, which answered today's calendar stamped with today's date. The habit source never ran. The matcher now declines any utterance ParseHabitQuery claims, which keeps the decision out of the source table's ordering. Two gaps in the same matcher. Sunday had only its dative plural listed, so "в воскресенье" found no weekday. "по выходным" named days that no weekday word matches, so it was answered with the whole-week profile. Both are recognised now, and the weekend is read back as two days rather than pooled. Found in review of #59. |
||
|
|
810076451f |
update: roll back what the restart actually deploys
On the deployment deploy/README.md documents, source_dir and install_dir are
the same tree and the restart command rebuilds the image from it. The
Dockerfile builds from cmd/ and internal/ and .dockerignore keeps the host
binaries out, so restoring the snapshotted binaries restored bytes nothing
reads. A bad commit therefore cost two health timeouts and two image builds
and ended in ErrRollbackFailed with an instruction to copy files back by hand,
which would not have helped either.
A deployment that rebuilds from source now has to say how the source is put
back. source_rollback "git" records the commit before the update and checks it
back out before the rollback restart. It refuses a dirty tree, because the
recorded commit does not describe one and a forced checkout would delete his
work. A build-from-source config that says nothing is refused by Validate, at
startup, rather than at the one rollback that mattered.
Also in this change, all from the same review:
- MethodPing, the one method a locked daemon answers. Preflight passed on an
unlocked daemon and the post-restart Presence read failed on a locked one,
so a good update read as SHE IS PROBABLY DOWN once the env key is gone.
- A dial failure is reported apart from a read failure. The documented
socket is under /var/lib/docker, which a non-root operator cannot
traverse, and "she is not answering" was the wrong diagnosis.
- Verify refuses to run as root over a tree owned by someone else. It runs
make build and make test in place, and root-owned artifacts break his next
ordinary make.
- A rollback no longer reverts config_files. That undid every config edit
since the last apply, phraser.model_path among them.
- The verify-failure path no longer reports rolled_back for a compile error.
- waitHealthy caps each attempt at the remaining budget, so a 90s timeout
cannot run to 99s.
- tail cuts on a rune boundary. Russian test names showed the seam.
- The claim that mavend does not import internal/update is replaced with
what is enforced: mavend constructs no Updater and nothing can call Apply.
- snapshot_dir inside source_dir is refused. It landed in the build context.
Found in review of #69.
|
||
|
|
fa799bc051 |
mavend: run the persona checks over the clarify prose, document the proposal cooldown
clarifyExpiredVariants and clarifyGaveUp are hand-written Russian that the phrasing eval never sees, because they never pass through the phraser. They carry feminine self-reference and a plain imperative, and they are the lines a later edit reaches for a synonym in. A table test now runs the eval's own feminine, his-gender, address and cringe checks over them and over clarifyQuestions. The apology clause of the cringe check is skipped with its reason written down: it exists so a greenlit nudge is not undercut, and a reply to a request she failed to parse is the opposite case. Also two notes and no behaviour change. announceProposal now says what its cooldown does and does not do: detectAndPropose returns non-nil only for a newly created row, so the first tick over a populated history announces one pattern and silences the rest permanently, and the cooldown only spaces genuinely new pairs found later. A queue would be needed for "one per day until each is mentioned". The duplicated Cooldown default is explained as cover for a tickLoop built in a test without going through Load. The -reembed flag help says the daemon does not answer until the backfill finishes. Found in review of #50, #54. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TrVSBKe3RFDF4fGYKWYQnX |
||
|
|
4757ff6d7b |
mavend: record which channel a quiet toggle arrived on
resolveQuietToggle runs inside runTurn, so mavweb /api/chat and telegram reach it as well as the microphone. Every toggle was written with Source "tap:voice" regardless, which left the facts table claiming a mic flipped a setting nobody spoke to. This is the one function whose own doc comment calls it a network-reachable way to change a daemon-wide setting, and provenance is the first column read when asking why quiet mode is on. runTurn now takes the channel it was entered from and the toggle writes it: "tap:voice" from HandlePushToTalk, "tap:text" from handleText. Found in review of #53. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TrVSBKe3RFDF4fGYKWYQnX |
||
|
|
7ab9b48259 |
coldstart: recover v1 boxes, and make key wrapping an explicit act
Three ways the cold-start path could lose the database. A box enrolled before the PRF change could never cold-start again. UnwrapKey still read v1 blobs, but the only caller stopped supplying the v1 secret: the assertion handler sends the PRF output and nothing looks up the credential public key any more. On such a box the daemon read the blob, took the v1 branch, failed to decrypt, and stayed locked while a valid passkey was asserted at it. The escape hatch was gone too, because WrapKeyFn was wired only in env-key mode and a locked boot is by definition the mode with no env key. The recovery was to put MAVEN_DB_KEY back in the environment, which is the thing cold-start unlock exists to avoid. AssertFinish now retries a failed PRF unwrap with the credential public key, and WrapKeyFn is wired in locked mode too, so the box that came up on a v1 blob can be moved to v2. Wrapping ran on every successful assertion. That made a routine step-up rewrite the one file that opens the database, under whatever 32 bytes the page posted. A compromised /auth/webauthn converted one legitimate touch into permanent offline recovery of the at-rest key, and a second enrolled authenticator silently locked out the first. Wrapping is now an act of its own: a plain assertion may write the blob only when none exists, and replacing one takes the rewrite button, which is the only caller that sets the new explicit flag. The daemon still refuses to overwrite a v2 blob that does not open under the presented secret. The write was os.WriteFile, which truncates in place. A power cut between the truncate and the write left a zero-length blob and no previous contents, on the path of every step-up. It is now a temp file in the same directory, fsync, rename, fsync of the directory. Two smaller things on the same path. The v2 unwrap checked the secret length but not the all-zero case the wrap side rejects, so the two ends disagreed about what a valid secret is. And the handler logged "daemon unlocked via credential" when an env-key daemon had answered unknown method, and again when an already-unlocked daemon had done nothing. Left alone deliberately: the PRF value is client-supplied and not covered by the assertion signature. That is inherent to PRF key wrapping, since the salt has to be fixed for the blob to open on the next boot. It is recorded as a known property where the secret enters the handler. Found in review of #77. |
||
|
|
aee20a6abc |
llm: give voice turns priority on the single llama-server slot
llama-server is started without -np, so it serves one request at a time and everything else queues. Mail extraction is allowed two minutes on a Thinking 1.7B, and the reader hands core up to 25 messages back to back. A turn arriving mid-extraction therefore waited for whatever was left of that budget: the router timed out into the classifier cascade and its 36.8% floor, and the phraser, which has no floor, simply waited. Memory evaluation had the same shape with a five minute budget. llm.Gate is the bound. Foreground requests never wait. Background requests run one at a time and yield while a foreground request is in flight, plus a quiet window after it that covers the gap between the router call and the phraser call of one turn. Clients get their priority from llmClientFor or llmBackgroundClientFor, so which side a caller is on is decided at wiring time. It gates only what goes through those clients, which the comment on Gate says. mail intake: the extraction timeout no longer wraps the capture writes. A model answering at 119 seconds of a 120 second budget left the first CaptureTask one second and the third none, so candidates the model had already produced were dropped with a deadline error. The mailbox name is validated before it becomes provenance, since "email:" is not a source and neither is an arbitrary string posted at the socket. The enable log prints the normalised candidate bound rather than the configured one, which said "max 0" and then wrote three. Found in review of #64. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TrVSBKe3RFDF4fGYKWYQnX |
||
|
|
b2eb08bb51 |
mavend: take the clarify expiry notice before the confirm turn
runTurn computed the notice at step 2, after the confirm check had already returned. So he could be asked a question, walk off until it expired, come back and say "да" to a confirm that was still parked. The confirm answered and he never heard that the older request had been let go, even though the store had dropped it. Every other exit from runTurn carries the notice. The notice is now taken first and every early return wraps in withNotice, including the clarify answer path, where it is empty in practice because one dialogue id holds one question. Found in review of #50. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TrVSBKe3RFDF4fGYKWYQnX |
||
|
|
ba33a677f8 |
memory: canonicalise habit keys, take the median on a clock, name the period
Three defects in how the counted profile is read back. The counting unit was the key the LLM invented. There is no allowlist and no normalization behind it, so "я выпил воду" and "попил воды" landed as different keys, split one habit into two, and dropped both below the two-day threshold. Keys now go through an alias table in behavior_ru.json before they are counted. An unglossed key is quoted rather than recited as a verb, because "обычно ты выпил_воды около 09:00" is not a sentence. The typical time was a median of minutes since midnight, which is wrong for anything that straddles midnight. Bedtimes of 23:40, 23:50, 00:10 and 00:20 gave 12:00, on the one activity most likely to cross the boundary. It is now a circular median, and when the observations span more than half the clock she names the habit without a time instead of inventing one. The rest is wording. Profile.Since was computed and never spoken, so "обычно" was an unfalsifiable claim; the overall read-back now says over how many days of records it holds. The no-data weekday answer said "у меня пока нет ничего постоянного" about a question concerning him. And "quiet" was a bare prefix in the non-behavioural list, so any future self-fact key starting with those five letters would have been dropped. Found in review of #59. |
||
|
|
f891a81ab2 |
clarify: ask about the second missing slot instead of failing on it
wantedSlots says a reminder needs both a subject and a time, but askClarify parks only the first gap, because she asks about one thing per turn. When both were missing the second gap was never revisited. "напомни" with no subject and no time asked "О чём напомнить?", accepted "позвонить маме", then handed applyAction a reminder with no time, which answered "не получилось разобрать время напоминания." That is a parse error for a question she never asked. A filled gap now re-enters the clarify loop for whatever wantedSlots still names, one question per turn as before, spending the same attempt budget so the exchange stays bounded. The answered subject is also folded into the raw utterance, because actionReminder stores the utterance as the payload and a reminder clarified out of a bare "напомни" would otherwise fire saying nothing. Found in review of #50. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TrVSBKe3RFDF4fGYKWYQnX |
||
|
|
38b09ded95 |
store: return one calendar row per event
CalendarEvents range-scanned the key prefix and returned every historical row, voided ones included. The facts table is append-only and the event key is day plus summary, so moving a standup from 14:00 to 16:00 left two rows under one key. The day plan prints a time per line, so it recited both and told the owner he had two standups. The query now drops voided rows, keeps the latest row within a source, and prefers the best-evidenced source across them, so a notification relay guessing at a meeting cannot displace the calendar read of it. Found in review of #58. |
||
|
|
6c81df17ec |
email: drop the dead Gmail rule, fix nested MIME, decode windows-1251
The Gmail category rule matched X-GM-LABELS and X-Gmail-Labels against the parsed header block. Neither is a header. X-GM-LABELS is a Gmail FETCH data item and never appears in the message source, and X-Gmail-Labels only exists in a Takeout export, so the branch could not fire against a real mailbox while its doc comment promised a Promotions filter. Its test built the header by hand and therefore asserted the matcher rather than the plumbing. The rule is removed and the comment says what bringing it back would take. multipartText folded a nested multipart's answer into one string, so HTML derived text landed in the plain bucket and a real text/plain sibling later in the message was discarded by the guard on plain being set. The two buckets now stay separate through the recursion. windows-1251 returned an unsupported-charset error and the message degraded to subject only. That is the charset older Russian senders still use, so those mails could never produce a task candidate. It is decoded from a 128 entry table here rather than by vendoring x/text, for the body and for encoded words in the subject. Every other unknown charset still degrades to subject only. Found in review of #63. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TrVSBKe3RFDF4fGYKWYQnX |
||
|
|
d69a1f8076 |
email: bound the IMAP read and keep one bad message from blocking the poll
The literal size came off the wire with no cap, so the server chose the
allocation. A {2147483647} literal was a 2GB make before a byte arrived, and one
ordinary mail with a 60MB attachment was 60MB of peak RSS on a box already
holding a 1.7B model resident, all of it discarded afterwards by plaintextBody.
Literals are now capped at MaxMessageBytes, and a larger one is drained and
reported as ErrMessageTooLarge without being kept. Reads are chunked with a
deadline refresh, so the timeout is an idle timeout again rather than a budget
for the whole message.
FetchSince returned on the first fetch error, though its comment described a
continue. One oversized message at the top of the window hid every older message
behind it, on that poll and on every poll after it. Failures are now collected
and the rest of the mailbox is read. An oversized UID is retired as bulk, since
it will be the same size next time and the poller marks bulk seen.
Timeout zero was accepted and disabled the dial timeout and every socket
deadline, which parks the poller forever on a dead server with his credential
live in a TLS state. It is now rejected like an empty address.
A FETCH answered without a literal was indistinguishable from a vanished
message and dropped with no log line. Login now rejects a credential containing
a line break instead of stripping it and failing on the server's generic NO.
untagged matches the whole key, not a prefix. RunWith is gone: the dial seam is
an unexported field again, reachable only through export_test.go, so no code
outside the package can hand the reader a cleartext transport and the password.
Found in review of #63.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TrVSBKe3RFDF4fGYKWYQnX
|
||
|
|
e4bfcd958f |
netscan: stop the scan wedging, and stop it overstating the LAN
The results channel was sized by the number of hosts while each worker sends once per open port, so a subnet with more open ports than addresses filled the buffer and blocked a worker forever. Nothing drains the channel until wg.Wait returns and the sends have no ctx.Done case, so the calling turn hung for the life of the process. Size it by probes. Three more claims the scanner could not back. MaxHosts was spent in order, so the second of two configured subnets got two addresses out of 254 with nothing logged. A run cut short by the cap or the deadline came back indistinguishable from a complete one, and the shipped defaults never fit the budget, so every scan was silently truncated at the top of the range. Scan now reports truncation, targets are taken round-robin, and the default rate and the budget are consistent with a /24. The spoken reply read dotted quads out loud on the voice path. It now says how many devices and what shape, and writes the address list as a note, which is also the only record that Maven put packets on the LAN. The network noun is matched whole so posetil is not a scan, the rate has a stated ceiling, and a repeat question inside two minutes reuses the answer. Both query sources claimed the turn when the capability was off, which let an unconfigured scanner and an unconfigured house swallow questions that used to reach recall. Both now fall through. Found in review of #81 and #80. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TrVSBKe3RFDF4fGYKWYQnX |
||
|
|
012bdcc1ae |
memory: count habits over self facts only, and skip retracted ones
The behaviour profile read the newest 2000 rows of the shared facts table and then discarded everything that was not kind=self, so the length of the window was set by the noisiest writer. mavpoll writes a wg_handshake row every time a peer rehandshakes, about every two minutes per peer, which is enough to reduce 2000 rows to under three days. A weekday habit needs two distinct Tuesdays, so that window can never hold one, and she answered that she knows no habits on a store holding a year of taps. RecentActiveFactsByKind filters kind in SQL, and also drops rows a later row voids along with the void marker itself. The old read counted both a retracted tap and its retraction, so a fact he explicitly took back still shaped what she said he usually does. A correction still counts, because a correction is a value he stands behind. Found in review of #59. |
||
|
|
4f012e350c |
calendar: resolve DTSTART against its own TZID
parseDT stamped a zoned or floating DTSTART as UTC while every window around it is built in local time, so the two sides of every comparison were in different frames. On a +03 box a 22:00 local event parsed as 22:00Z, past the end of the local day, and the whole evening dropped out of the busy gate and the day plan. A 13:00 Moscow meeting read on a +04 box was recited at 17:00 next to its own printed 13:00. DTSTART now resolves three ways: a Z suffix is UTC, a TZID is loaded from the zone database, and a floating value is read in the caller's location. tzdata is embedded because the deploy image carries none, and a silent fallback to the box offset is the bug being fixed. FactKey and FactValue stamp the owner's clock, so the key date the store range-scans is the same day the plan asks for. FactSummary drops the time tail for callers that print the hour themselves. Found in review of #56 and #58. |
||
|
|
4e4c9170e3 |
calendar: date an ambient event by its day word, and refuse stale ones
EventFromNotification took the date from the notification's own day, on the grounds that a meeting notification is about today or it would not be firing. Calendar apps break that. A 21:00 reminder reading "Tomorrow at 09:00" became an event at 09:00 today, twelve hours in the past, and FactKey filed that wrong meeting under today's date. Storing a wrong meeting is the one outcome this parse works to avoid. An explicit day word now moves the date: завтра, tomorrow, послезавтра, сегодня, today, tonight. Matched whole, so послезавтра is not read as завтра, and stripped from the summary so the meeting is not named after the day. Anything still landing more than two hours before the notification is refused, which covers the cases with no day word at all. The grace keeps a repost for a meeting already under way. Also matches the bearer scheme with EqualFold. A phone sending "bearer <tok>" fell through to the X-Maven-Token branch and got a 401 that looked like a wrong token. A bare token with no scheme in Authorization is now rejected rather than silently accepted. The route table in mavweb gains its /api/ambient row, and the missing calendar_busy write is recorded as a known gap. Found in review of #57. |
||
|
|
88c841cb0e |
memeval: scope the evaluator's note windows by source
Both windows the evaluator keeps over the notes table were row budgets over every writer. The dedupe read 200 recent notes and kept the eval ones, so after 200 ordinary notes an old observation left the window and the next evaluation wrote the same sentence again. The snapshot asked for MaxItems notes and then discarded her own, so once hourly evaluation had run for a few weeks the model saw almost no real notes. Both reads are now filtered in SQL, by RecentNotesBySource and RecentNotesExcludingSource. Two smaller things in the same area. The dedupe key stripped any trailing bracketed clause, so an observation ending in one hashed differently from its stored form; it now strips only the recorded action. The evaluation timeout was five minutes on the one llama-server that also answers voice turns, which made a collision a five-minute mute assistant, and is now sixty seconds. Found in review of #55. |
||
|
|
0e83ddf3df |
deploy: stop mavweb becoming the default nginx server by file order
The maven block was first in nginx.conf, and nginx serves the first block for a listen address when no server_name matches. Those two ports used to default to nexus. After the maven block landed, a request with an unknown or absent Host header reached mavweb instead, which is the one surface in the file that can define and run argv. The ACL still held, so this was not an exposure, but it is the wrong default to acquire by accident. The nexus block is now marked default_server so the choice is explicit, and the maven block moved last as a second guard. Also raises client_body_timeout and proxy_send_timeout to match client_max_body_size 32m, since a slow push-to-talk upload was cut at the 60s default on both while proxy_read_timeout was already 300s. Found in review of #52. |
||
|
|
49dfeb879e |
mavweb: gate the voice path on step-up like the text path
POST /api/ptt and /ws were listed as ungated on the grounds that mavend's voice port is only reachable inside the deploy. mavweb is the thing proxying into it from outside, so that argument does not hold. Audio posted to /api/ptt runs the same router, the same LLM and the same applyAction that POST /api/chat was gated on, which means speaking a light-switch act reached the act path while typing it did not. Both now take stepUpOK, so they fail open by default and deny under -require-stepup exactly like the other four. Registration moved down next to /api/chat because the gate needs stepUpSession. The route table records the reason and names the session-scoped assertion the hands-free case wants as a separate task. The SECURITY startup lines are one surface per line now. Found in review of #51. |
||
|
|
7f42cc73be |
Address PR review comments on 50, 52, 53, 54, 59, 61
Seven fixes, each answering a line comment on the stack.
**Weather no longer invents Moscow** (PR 50). extractWeatherLocation returned
the string "Moscow" when he named no city and voice.weather.default_location
was unset — a made-up answer presented as fact, which is the one thing maven
must never do. It returns "" now and the query path says it does not know.
**Digest statuses are a defined type** (PR 50). DigestStatus string plus the
three constants, so a rule name cannot reach the status column.
**Quiet-mode negation is not adjacency** (PR 53). The OFF list carried
{"не","тих"}, an adjacency pattern, so "не надо тихий режим" missed OFF, hit
the ON pattern {"тих","режим"}, and asking for quiet mode to stop turned it
on. Negators are scanned over the whole utterance now, with the two ON phrases
that are themselves built on "не" excluded. "тихий режим выключи" works too,
which it did not before.
**Pattern stability uses a median band** (PR 54). max/min over the extremes
asked whether every gap resembles every other gap, so 7,7,7,7,20 — four clean
weeks and one holiday — was thrown away at a ratio of 2.9. Each interval is
now tested against the median and 70% must be in band, and the reported
interval is the median of the in-band ones, so a holiday no longer drags a
weekly habit to "every 9.6 days". The reviewer's 5,8,10,3 is still rejected.
**The weekday profile stops reciting everyday habits** (PR 59). "What do I do
on Saturdays?" answered "you drink water" — true, and useless, because it is
equally true of every other day. Activities that are habits on six or more
weekdays move to Profile.Everyday and are read back as daily habits instead of
as an answer about that day.
**Russian phrase tables move out of Go** (PR 59, PR 61). The behaviour glosses
and weekday names, and the task capture/urgency/list vocabulary, are now
behavior_ru.json and task_phrases.json, embedded with go:embed. Single-binary
deploy is unchanged; wording edits are no longer source diffs.
**nginx template stops taking nginx down** (PR 52). Two host-side failure
modes, both plausible causes of today's crash. The $connection_upgrade map is
fatal when duplicated, so it moved to its own nginx-upgrade-map.conf with a
grep-first note. And `listen 10.42.0.1:80` fails with EADDRNOTAVAIL when wg0
is not up yet, so nginx exits on a reboot that beats WireGuard — the header
now documents net.ipv4.ip_nonlocal_bind.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TrVSBKe3RFDF4fGYKWYQnX
|
||
|
|
927e46bca3 |
Version, authenticate and fully trace ecosystem calls (#273)
Every Nexus and Praxis request now carries the contract version, an X-Requested-By identifying Maven, a correlation ID (generated per request when the call is not part of a traced action), and a bearer token when one is configured. Nexus/Praxis/Hexis config blocks grew an optional token field, env-expandable so the secret stays out of the committed config; the vendored hexis client predates bearer auth, so a configured Hexis token logs a loud warning instead of pretending to authenticate. Client failures are now a typed *ecosystemError carrying service, operation and HTTP status, classifying unauthorized, contract-mismatch and unreachable without matching on message text. Trace records are written for resolution, discovery, confirmation and execution — on failure as well as success — with status, duration, correlation and causation ids, HTTP status and failure class, and the utterance redacted to its length. Traces were never actually persisted before: both trace writers used fact kind "system", which the store's CHECK constraint rejects, and the error was discarded. |
||
|
|
08f3db318f |
Query Praxis by canonical entity ref and back off enrichment retries (#272)
Add an entity-scoped attention capability: the subject is resolved to a canonical Nexus entity_id, the id travels to Praxis as a query scope instead of being dropped after resolution, and Maven's own facts already tagged with the same id join the answer. Ambiguous, unknown, degraded and no-Nexus cases each get a distinct reply and never a scoped query without a scope. Give the fact-enrichment worker per-fact exponential backoff capped at an hour and a status report of pending/in-backoff/worst-attempt counts, so a long Nexus outage shows as a visible backlog rather than facts that silently never got tagged. Nothing is ever given up on. |
||
|
|
69e2800ef3 |
Cover ecosystem degraded modes with a shared fault-injection harness (#276)
Extend the fake Nexus/Praxis/Hexis harness with request header and query capture, a malformed-body lever, a response delay lever, and a request counter, then add a degraded-mode suite on top of it: independent outages, malformed and drifted contracts, cancellation, execution failure vs transport failure, ambiguous targets, no autonomous Praxis to Hexis chaining, confirmation for mutating capabilities, and recovery without a restart. |
||
|
|
a8fcb404be |
Scan the LAN, bounded to configured subnets (#257)
internal/netscan/ discovers hosts on the network Maven is configured to look at:
a TCP-connect scan (net.DialTimeout, no raw sockets, no privileges) plus a read
of the kernel's ARP cache. Wired as a read-only query source, "network", so
"какие устройства в сети?" is answered by a scan instead of by whatever old note
happens to be nearest.
Scanning is a read, but an unbounded scanner on a home LAN is noisy and easy to
point somewhere it should not go, so the package is built around four bounds:
- Scan takes NO target argument. The range comes from the config block and
from nowhere else, so there is no exported way to scan an arbitrary prefix
and nothing an utterance, the router, or a scanned host says can retarget
it. That is asserted directly: the test watches every address handed to the
dialer and fails if one falls outside the configured prefix. The ARP cache —
the one input the network itself populates — is filtered to the configured
range for the same reason.
- Every configured CIDR must be private (RFC1918 / CGNAT / link-local) and no
larger than 1024 addresses. 8.8.8.0/24, 0.0.0.0/0 and 10.0.0.0/8 are refused
at config load, not after the packets have left.
- Rate-limited to a configured connections-per-second across the whole scan,
so it looks like background traffic rather than a portscan.
- Bounded in total by MaxHosts, a per-connection timeout, a 20s turn budget
and the context; a canceled scan stops dialing immediately.
Off unless configured: dark without "enabled": true, and applyDefaults
normalises a disabled block to nil. deploy/mavend.json carries it disabled.
BLUETOOTH IS NOT SHIPPED, AND IS BLOCKED, NOT SKIPPED. The plan's other half
(internal/bluetooth/, RSSI presence probes) needs a bluez stack that is not
here: bluetoothctl and hcitool are not installed, bluetoothd is not installed,
the bluetooth unit is inactive, and org.bluez is not on the system bus. hci0
exists as a kernel device and nothing can talk to it. The docker deploy is
further away still — it would need host networking, the D-Bus system socket
passed in, and CAP_NET_ADMIN. Writing an exec wrapper around a binary that does
not exist, against an output format nothing here can produce, would be a guess
dressed as a feature. It needs a decision about privileging the container before
any of it is worth writing.
Vikunja #257
|
||
|
|
dc4c5b7841 |
Read and control the house through Home Assistant (#256)
A `smarthome` block points Maven at a Home Assistant instance. She reads its entity states to answer "что включено дома?", and every controllable device becomes a PROPOSED row in the existing act allowlist — cmd ["smarthome",<entity_id>,<service>], scope smarthome:<domain> — so nothing new had to be invented for the mutating half. ProposeTool/EnableTool/DisableTool, tool.Matcher and the confirm turn are untouched; one branch in Executor.Exec routes such a row to the client instead of exec, and "smarthome" is never run as a binary. This is the same trick overnight/mcp-tools used for #251, on purpose. Discovery only ever PROPOSES, and every control row is destructive=true: there is no read-only way to turn the heating off, so flipping something in his flat always costs a confirm turn and always had to be enabled by hand on /tools, behind step-up. The entity and the service come from the row he enabled, never from the utterance — Exec drops the spoken tail for a house row. A router that misheard can pick the wrong lamp; it cannot compose a target of its own. The service is checked against the domain's table on the way out too, so a hand-edited cmd column cannot reach an arbitrary Home Assistant service. set_brightness and set_temperature are deliberately absent: a spoken number the router got wrong is a wrong act on real hardware, and on/off is the whole of what a voice turn can defend. The read side is a query source ("home", before calendar and the recall passes) so "что нового дома?" is not answered from an old note. Its matcher needs a house marker plus an ask plus a device word and bails out on weather wording, because "какая температура на улице?" belongs to the weather source. Off unless configured: the block is dark without "enabled": true, and applyDefaults normalises a disabled block to nil so "off" stays in one place. deploy/mavend.json carries it disabled, with the token as ${HA_TOKEN}. NOT shipped, and not faked: MQTT / Zigbee2MQTT (plan steps 2 and 5) and the sensor-to-fact and presence-probe pipelines. There is no broker and no Home Assistant anywhere on this network — 8123 and 1883 are closed on every host in 192.168.1.0/24 — the module tree is vendored so a paho dependency cannot be added offline, and Home Assistant already fronts Zigbee2MQTT where it exists. Writing a sensor pipeline with no sensor to test it against would be a guess. Vikunja #256 |
||
|
|
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 |
||
|
|
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. |
||
|
|
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
|
||
|
|
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. |
||
|
|
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. |
||
|
|
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 |
||
|
|
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 |
||
|
|
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. |
||
|
|
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 |
||
|
|
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.
|
||
|
|
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
|
||
|
|
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
|
||
|
|
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. |
||
|
|
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. |
||
|
|
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 |