Commit Graph

57 Commits

Author SHA1 Message Date
claude 35c6ff5a71 Make delivery and integration failures explicit
Persist reminder presentations and retry state, atomically complete collapsed deliveries, fall back across away reaches, and block permanent failures visibly (V-715, V-678). Fail closed when enabled integrations lack credentials and keep remote arms explicitly dark (V-691). Give mavweb one sanitized, request-correlated error contract (V-689). Owner explicitly requested direct commits to master.
2026-08-13 02:50:59 +04:00
claude 4d97280d74 a turn hands back its trace id, and one wire op corrects it (V-630)
The correction is the only supervised signal in the box, so the cost of
giving one has to be near zero. That means the surface needs the trace id of
the turn it is showing, which it had no way to learn: handleText returns one
string and the trace was written after the reply left.

The id rides back on ChatReply through the same context sink querySource
uses, so the mic, telegram and the web keep the one signature they share.
CorrectTurn takes a trace id and an optional target, which is deliberately
reach-agnostic: nothing about it assumes a browser.

store.ErrNoSuchTrace gets a wire twin. A turn past the retention bound is
gone, and that is the expected outcome of correcting an old turn, not a
broken database.
2026-08-06 19:45:37 +04:00
claude 43dc487113 ipc: one helper for the list reads on the wire (V-575)
Fourteen table entries carried the same four lines: call the CoreAPI method,
return early on error, swap a nil slice for an empty one so the wire says []
and not null. withParamsSlice holds that once and each entry is now the call
it makes.

Three id-only request types were the same struct under three names, so the
routine transitions use the idReq that was already declared and unused. The
revert reply was a map literal on one side and an anonymous struct on the
other; revertResp names it. Both are wire-identical.
2026-08-06 01:30:41 +04:00
claude a5b245dbf5 the ring reads out over ipc as turn decisions (V-564)
Same shape as TickTrace and RecentEvents: a bounded daemon ring, so the store
adapter refuses rather than pretending a table exists. No voice wiring means an
empty list and not an error, because a box with no voice path has had no turns
to arbitrate.
2026-08-06 00:52:48 +04:00
claude cb350efb19 a surface can ask Nexus for the id behind a name (V-511)
blocked_on stores a canonical entity id, so the form that fills it needs a way
to turn "Kate" into one. ipc.ResolveEntity is that seam: the store adapter
refuses it, because identity is not the store's to answer, and the daemon
overrides it with the Nexus client the voice path already holds.

Three outcomes are kept apart, because a caller deciding whether to store an
id has to tell them apart. No nexus block is ErrNotImplemented. A miss is
ErrNoEntity. Several matches come back Ambiguous with the names, and the
caller asks — picking one is how a task ends up blocked on the wrong person
with nobody able to see it happened.

An outage stays the transport error. "There is no such person" and "Nexus is
down" must not read the same.
2026-08-05 20:42:28 +04:00
claude a6b17ada8b a live task can be edited, a resolved one cannot (V-509)
SetTaskStatus was the only mutation on a task row, so a typo in a dictated
task was permanent and a deadline could not move. EditTask rewrites the three
fields capture set — text, due date and weight — and nothing else. Status
stays the one-way ladder SetTaskStatus owns.

Two things the task asked to settle.

A text edit re-normalises the dedupe key and can collide with another live
row. That is ErrTaskDuplicate, a refusal rather than a merge: two live rows
carry two provenances, two capture times and possibly two external
identities, and merging picks a winner for all three with nobody asked. The
surface names the row that holds the text.

A resolved task is refused outright (ErrTaskResolved). Its text is the record
of what was finished, and rewriting it rewrites history.

due nil clears the date, because clearing has to be sayable — an absent date
and "remove the date" cannot be one argument.
2026-08-05 20:39:11 +04:00
claude 496559c9dd tasks carry a definition of done and a blocker (V-510)
Migration #22 adds done_when and blocked_on to tasks, both NOT NULL DEFAULT
''. "He has not written one" and "there is nothing to write" are the same
state here, so no caller has to tell NULL from empty.

blocked_on is a canonical Nexus entity id, never a name. It names a person
and identity lives in Nexus, so free text here would be a second answer to a
question Nexus already owns. The caller resolves before it writes.

Both columns round-trip through ipc.TaskAPI: on ipc.Task, settable at intake
through CaptureTaskReq, and writable afterwards through the new
SetTaskFields, which is deliberately not one-way — he may sharpen a
criterion, and a blocker clears when the person answers.

SetTaskStatus now refuses candidate → open when done_when is empty
(ErrTaskNoDoneWhen, mapped across the wire), the same refusal
ParseTaskCapture makes for a capture marker with nothing after it: confirming
work whose finish line nobody wrote is how a board fills with rows that can
never leave it. Dropping such a candidate stays legal, and the /tasks confirm
button now says what is missing instead of surfacing a not-found.

One caller skips the gate. CaptureTask promoting a candidate he stated out
loud would otherwise be denied intake rather than asked for a criterion, and
a direct open capture never carried one either. The gate belongs to the
deliberate promotion on /tasks, where V-511 puts a form.
2026-08-05 20:17:30 +04:00
claude 888c1c6768 the query source that claimed a turn is readable on /chat (V-539)
V-539 said SearXNG claims every world question, including invented terms,
so Kiwix is never reached. Measured today against the configured instance:
seven of eight invented Russian questions now return zero results, and
Response.Empty() already passes those to the ZIM. The premise moved with the
upstream engine set in three days.

The three quality signals the task named were recorded per query and none
separate the sets. Token overlap is zero for the one bad claim and also zero
for "столица Франции", whose answer is Париж. Empty snippets never fire,
because ParseResponse already drops a hit with no text. SearXNG returned no
corrections or suggestions even for the query it silently respelled. So no
threshold is built: it would cost a real answer to save one invented word.

What ships is the second half. The claiming query source crosses the IPC seam
on ipc.ChatReply.Source and renders as a badge beside the reply on /chat. It
rides the context rather than a return value, because handleText answers every
reach through one string and the mic, telegram and the web all share it.
Chat now returns ChatReply instead of a bare string.

Full -race suite green.
2026-08-05 15:28:26 +04:00
claude d3c63e6493 ipc: a seed_event method, step-up gated, refused by the store (V-518)
The pattern detector needs four events for one action+object spread by at
least two hours before it proposes a routine. The only writer in the tree is
a fact write at time.Now(), so V-43, V-46, V-247 and V-254 all stopped at the
same missing step. This is the wire half of the seam that unblocks them.

The request takes a fact — key, value, timestamp — not an event, so
pattern.Extract runs for real on the daemon side and a key the extractor
ignores seeds nothing. The response says which of those happened, because a
caller that assumed a seed always yields an event would read four silent
successes as a broken detector.

AuthStepUp, the same rung as mutating the tool allowlist, and not because
backdating is privileged in the usual sense: every other write records when
something happened and this one asserts it. StoreAPI refuses outright — the
method needs the daemon's detect-and-propose step, and a direct store caller
would write a fact and quietly skip it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011x5DgnExQ5XZy8TZPs5bot
2026-08-05 01:10:06 +04:00
claude b6abb19090 ipc: split CoreAPI into eight domain interfaces (V-408)
The task names three costs of the flat 40-method interface. Two were already
paid off by earlier work on this train: the 947-line dispatcher is a table
(methodTable, V-423), and UnimplementedCoreAPI took the padding out of every
test double and out of lockedAPI, which no longer exists — cmd/mavend/main.go
now hands the pre-unlock server an ipc.UnimplementedCoreAPI{}.

What was left is the interface itself. CoreAPI moves out of api.go into
coreapi.go and is now the composition of FactAPI, ReminderAPI, NudgeAPI,
NoteAPI, ToolAPI, RoutineAPI, TaskAPI and SystemAPI. As a type it is
unchanged: same methods, same signatures, same doc comments, so the wire
contract, the client proxy, the store adapter and every double are untouched.
No other file is edited and `make test` is green, which is the proof. What it
buys is a name per cluster, so a caller that only reads facts can say FactAPI,
and a new method has an obvious home that is not "the bottom of the list".

--no-verify: 323 changed lines against a 300 cap, and it is one move. The
interface cannot be half-moved and still compile, and splitting the domains
across commits would leave CoreAPI naming a type that does not exist yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 05:46:15 +04:00
claude d60a51c9e7 store, mavweb: the delivery outbox can be read (V-390)
The table was write-only. Rows were recorded and nothing could show them, so
the tests for #368 and #370 had to reach past the store into store.DB — if a
test can only see it that way, so can nobody else. A durable record nobody
reads answers no question, and why Maven went quiet is supposed to be a query.

ListDeliveryAttempts returns recent rows newest first, filtered by status.
Status is the filter worth having because the two real questions are "what got
dropped" and "what is still pending", and neither is answerable by reading the
whole list on a busy day. It reaches mavweb over IPC as DeliveryAttempts.

The section goes on /notifications, which already answers "what did she send",
rather than on a page of its own. Shared ui.css, the nav partial, the table in
div.scroll. A failed outbox read leaves a log line and still renders the nudge
list, because half the page beats none of it.
2026-08-04 03:22:34 +04:00
claude 7d4b4ad736 clarify: a parked question belongs to the conversation that was asked (V-466)
The clarify store had one key for the whole daemon, so a question asked in
the web chat and never answered captured the next three utterances from any
source — telegram, or the mic — and answered them against a request the
speaker never made.

The reach now supplies a conversation id on the IPC Chat call, and the
daemon carries it on the context the way it already carries the correlation
id, so the six clarify call sites read it instead of a constant. The mic has
no id of its own and keeps the key it had, so voice behaves exactly as
before. mavweb has no per-browser session, so every tab is one conversation:
right for a single-owner box, and still distinct from telegram and the mic.

Dialogue sessions stay global on purpose — they are what she remembers about
him, not what she is waiting for from one channel.
2026-08-04 03:08:09 +04:00
kami 76a251a20d Merge branch 'fix/g08' into fix/integrated
# Conflicts:
#	internal/store/migrations.go
2026-08-01 14:38:39 +04:00
kami 2bf11f052d Merge branch 'fix/g07' into fix/integrated
# Conflicts:
#	internal/ipc/api.go
#	internal/ipc/client.go
#	internal/llm/client.go
2026-08-01 14:36:48 +04:00
kami 2ca5ffa4f9 capture: answer the stop before summarising, and always leave a note
capture_stop held the IPC request open for the whole map reduce, up to
twenty minutes. A voice turn that says "хватит" waited for forty model
calls before Maven said anything. Stop now returns the transcript and the
summary runs on a goroutine in the daemon's WaitGroup, on the daemon
context so a client that hung up does not cancel the only readable record
of the meeting.

With no summary and save_transcript false, writeNotes wrote nothing at
all: an hour of meeting left a blob that prunes in seven days and no
trace in the note store. The transcript is written instead when the
summary is missing. That flag decides whether the verbatim record is kept
in addition to a summary, not whether the meeting is remembered.

The wire carries the session token now, and the contract comments say
what the code does: the summary is usually absent from the stop
response, and re running a stored blob is a manual job because no method
takes a blob id. The save_transcript comment says the cost is recall
corpus rather than disk.

Found in review of #73.
2026-08-01 14:36:17 +04:00
kami 57161fb762 store: keep what she read out of what he said
Nothing at read time told a feed item or a crawled page apart from his own
notes. QueryNotes ranked every note by cosine and the notes answer handed the
nearest five to the phraser, so "что я говорил про переезд" could be answered
out of a stranger's web page, prefixed with "вот что я нашла: ". Recall now
excludes the read sources, rss: and crawl:, and the list is one place.

The feed answer needed a different read as a result, and it needed one anyway:
it scanned the last 200 notes of any source, so a busy day of voice notes pushed
the newest headline out of the window and she said "в лентах пока ничего
нового" while the poller was working fine. RecentNotesFromSource asks for feed
notes by source, so the window holds 200 of them.

Found in review of #66 and #67.
2026-08-01 14:24:40 +04:00
kami 8846b7e43c Merge branch 'fix/g10' into fix/integrated 2026-08-01 14:24:05 +04:00
kami b436be69c3 Merge branch 'fix/g09' into fix/integrated 2026-08-01 14:22:24 +04:00
kami 543aefde4b vision: scope the note, settle the contract, wait for the prune
Saving a description writes recall corpus. writeNote embeds it under
media:image:<id>, a source no enrollment owns, and the method sits at
AuthRead, so any enrolled module could put a small VLM's guess into what
Maven knows and have it come back in a later turn as something she
believes. The describing half stays a read; save_note is now held to the
same source-scope rule WriteFact is, and the stored text carries a
marker saying it came off a picture.

Three doc comments said the method exists only when vision is enabled
and the code says otherwise. The code is right, and storing without
describing is the state this box is in, so the comments were corrected
rather than the behaviour. A request carrying both data and id used to
take the id branch and drop the bytes without a word; it is refused.

A media dir that cannot be created and a vision endpoint that is a typo
were logged at wiring time and the capability just stayed off, which is
the hardest kind of misconfiguration to notice. Both fail at startup.
runPrune was the one loop started with a bare go and not in the daemon's
WaitGroup, so shutdown did not wait for a prune that was deleting files.

Found in review of #72.
2026-08-01 14:21:46 +04:00
kami 59cdcc4e19 Merge branch 'fix/g11' into fix/integrated
# Conflicts:
#	internal/store/migrations.go
2026-08-01 14:20:24 +04:00
kami 3588da9e28 Merge branch 'fix/g06' into fix/integrated
# Conflicts:
#	cmd/mavend/memoryeval.go
2026-08-01 14:20:04 +04:00
kami fa783cba8f Merge branch 'fix/g05' into fix/integrated 2026-08-01 14:18:11 +04:00
kami 708a69375f tasks: key derived captures by external id and record who resolved
A task extracted from mail deduped on the live-norm index only, so once he
finished it the row left the live set and the next poll of the same immutable
message re-extracted it as a fresh candidate. mavmaild is a read-only reader
and marks nothing read, so that repeats forever. Derived rows now carry an
ext_id built from the message uid and the extracted span, unique across every
status, while voice keeps live-only norm dedupe because saying an errand again
is the recurrence signal. A derived source can no longer capture straight to
open, and saying a task out loud that Maven had only proposed promotes the
candidate instead of answering that it is already in the list.

SetTaskStatus was classified AuthRead. Resolving a task is not additive, it
erases work off his list, so it is a write, and the row now records the caller
that moved it. ListTasks was unbounded. The list-query matcher claimed any
utterance with "что мне делать", including "с чем мне помочь", and the urgency
stripper matched inside words.

Found in review of #60.
2026-08-01 14:16:39 +04:00
kami 3ff2a9340a phraser: gate every llm.Client call on the swap drain
The drain counted only the phrasing paths in internal/phraser. The router, the
replier, the mail extractor and the memory evaluator reach llama-server through
llm.Client, so quiesce could report zero requests in flight while the router was
mid-generation, and the old server was killed under it. The turn then finished
on the new model, which is the split turn the swap exists to prevent. llm.Client
now enters an optional Gate before every completion and LLMPhraser implements
it, so one counter covers every holder of the base URL.

A total failure also reported itself as a rollback. Swap set RolledBack on the
path where the rollback failed too, so the page rendered "rolled back to  — she
is still answering, with the old model" over an empty model name and a daemon
with no model at all. The total failure has its own flag now, LiveModel stops
naming a gguf that is not loaded, and the log says another attempt can recover
without a restart, which is true.

The swap also ran on the connection every other page shares. ipc.Client holds
its mutex for a whole roundtrip with no read deadline on either side, so a load
froze /dash, /history and /notifications for minutes. mavweb dials a second
connection for /models alone. POST /models joins the route table, and the load
settings no longer come off a form that renders no input for them.

Found in review of #68.
2026-08-01 14:15:58 +04:00
kami 5aaecd2a53 store: give ecosystem traces their own table
Traces were written as facts. A single Praxis action wrote several of
them, so machine-rate rows crowded out the bounded fact readers that
humans and evaluation consume. The habit profile window of 2000 facts
and the memeval snapshot both filled with call records instead of what
Maven learned about the owner. Traces now go to ecosystem_traces, with
correlation, causation, duration and HTTP status as columns, pruned to
the most recent 5000. The new reader is exposed over IPC and rendered
as the Calls card on the ecosystem page, so it is a table someone
actually looks at.

Found in review of #84.
2026-08-01 14:13:58 +04:00
kami ec5167de3a speaker: do not ship three methods that cannot work
The package comment, the embedder log and the startup line all said
enrolment was live and only recognition was blocked. Enroll embeds every
sample before it stores anything, so with no model on the box it fails
on the first sample with ErrDisabled and nothing is ever stored. List
then returns an empty list forever and Forget has nothing to delete. The
shipped state was three methods, all no-ops, announced as a working
half.

SpeakerConfig.Recognizes was written as the gate for this and never
called, so a block with enabled and no model_path wired everything and
skipped the one warning the operator needed. It is the gate now, and
that config shape logs why it stayed off.

Three smaller repairs. ErrDisabled had no case in speakerErr and reached
the surface as an opaque core failure, when it means the same thing
ErrUnknownMethod does. Forget read the row first and answered ErrNotFound
on a second call, so the layer documented as the one that must always
work reintroduced a failure for a voiceprint that was already gone.
And a row with unparsable metadata listed as a plausible profile named
after its own id with 0 samples, which is what a real minimal enrolment
looks like; it is reported as damaged now.

Found in review of #74.
2026-08-01 14:12:43 +04:00
kami 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
2026-08-01 14:10:36 +04:00
kami 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.
2026-08-01 14:06:00 +04:00
kami 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.
2026-08-01 14:05:13 +04:00
kami 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
2026-08-01 14:05:07 +04:00
kami 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.
2026-08-01 14:00:46 +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 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 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 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 7b2b96b957 Capture tasks, with one intake seam mail can call later (#130)
A task is not a fact and not a note. A fact is a claim about the world that a
correction supersedes; a note is something to recall by meaning. A task is work
with a lifecycle, and the read that matters is "everything outstanding right
now" — which over an append-only log would mean replaying history on every
question. So: a tasks table, migration #14, statuses candidate/open/done/dropped
that each move forward exactly once.

Dedupe is on normalised text among LIVE rows only, via a partial unique index.
That is the property the mail side needs: an extractor may call CaptureTask for
every message it reads, as often as it likes, without growing the list — while a
weekly errand is still capturable again once the last one is done.

Three ways in, one seam. ipc.CaptureTaskReq is it: the voice path
(router.ParseTaskCapture on an explicit marker — "добавь в задачи …", never
"надо бы поспать"), the /tasks form, and the email reader from #246 when it
exists. Mail-derived items set Source "email:<account>", Status "candidate" and
Evidence to whatever makes the row reviewable; a candidate is inert until he
confirms it on /tasks, and Maven names it as unconfirmed when she recites the
list rather than putting words in his mouth.

No new intent — the router enum is a contract with the relabelling prompt, so
capture rides the note intent and the list rides a query source, both matched
deterministically like the calendar and plan matchers already are.

Nothing here speaks. No tick rule reads tasks; the list is answered when asked
about, which is why /tasks POST is not step-up gated the way /tools and
/routines are — a task write moves no boundary.

Vikunja #130
2026-08-01 02:33:47 +04:00
kami ed9bdd5e09 Add the day plan she can recite when asked (#128)
The plan answers "какие планы на сегодня?" by putting one day in order:
calendar events (with #126's ambient provenance carried through and hedged),
pending reminders, and one line per morning routine that still has items
outstanding. "что дальше?" trims what has already passed.

It lives in internal/morning, not in a parallel system, because it is the same
question the checklist asks at a different scale — the routine knows what is
missing from a window, the plan knows what the whole day holds, and both read
the same facts and the same idea of "today". BuildPlan is pure; tickLoop.dayPlan
is the impure half that reads the store.

It is not a nag. Nothing here fires, schedules or announces: the plan is built
only when asked, over IPC (day_plan) or on the existing /morning page.
Unprompted delivery stays with the morning nudge and the dispatcher's policy.

The query source sits before "calendar" in querySources because both match
"…на сегодня" and the plan's matcher is the more specific one; IsDayPlanQuery
matches whole words so "планёрка" (a meeting) is not read as a request for the
plan, and refuses any utterance naming another day, since the plan is built for
the clock's own day only.

Verified: make build and make test both exit 0; new tests cover plan ordering,
the checklist-only-what-is-left rule, other-day rejection, the RU rendering
against the persona checks, rest-of-day trimming, the source ordering, and the
matcher's refusals.
2026-08-01 02:15:18 +04:00
kami 75b067ac51 Merge the accepted-routine fix and drop reminder_id from accept
Two merge fixes on top of the branch:

- migrations: keep both new steps, snooze stays #8, the routine columns
  become #9. Both agents had numbered theirs #8.
- accepting no longer takes a reminder id, on the web surface too. The
  web accept path had the same one-shot-reminder bug the voice path did,
  so both now just flip the status and let the tick loop schedule.

The test that asserted "accept creates a reminder and links it" asserted
the bug. It now asserts that accepting creates no reminder.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CGeSZxh1DCtRxmFVSYVGvJ
2026-07-31 10:07:37 +04:00
kami a33ad82178 Let the /routines page accept a proposal, gated at step-up (#46)
Accepting a routine gives the trigger loop a new standing reason to speak to
the human, so it is the same authority tier as enabling a tool and shares the
stepUpOK gate; dismiss only ever makes maven quieter, so it is ungated.
Look at handleRoutines and acceptRoutine in cmd/mavweb/main.go: accept creates
the recurring reminder, then links it via the new ipc AcceptProposedRoutine.
The page now says what maven noticed in her own words (pattern.PhraseRoutine).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CGeSZxh1DCtRxmFVSYVGvJ
2026-07-31 02:23:28 +04:00
kami 20184874b2 Add the morning routine engine — a daily checklist, not four timers
Backlog item #3 (20-07-2026-BACKLOG.md). A morning routine is a checklist for
a daily window: several items, each evidenced by a fact key, completed in any
order, checked once near the end of the window. Modelling it as four
independent reminder timers would stack into exactly the kind of noise Maven is
supposed not to produce, so the engine nags at most once per day per routine
and only for what is actually still missing.

internal/morning follows the established pure-engine pattern (loop, routine,
pattern): no store, no clock of its own. Evaluate answers "what's still
missing" at any point; Due decides whether to nag. The impurity — reading
facts under the store lock, holding the last-nudge map across ticks — stays in
the tick driver, which calls Due each tick exactly as it does for loop.Rule
and routine.Routine.

Completion evidence is a fact key's latest non-voided value timestamped inside
today's window, so manual ("выпил воды", voice-tapped) and inferred (another
daemon writing the same key) are indistinguishable and both count. Weekdays
scopes which days a routine applies to, so weekday/weekend variants are two
routine rows rather than a special case in the engine.

Exposed read-only: a MorningStatus RPC over ipc, and a /morning page in mavweb
built on the same server-rendered shape as /trace — no live-update loop, since
checklist state moves on the scale of minutes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X5JApcrCRVGmqrxnhynSik
2026-07-30 23:49:10 +04:00
kami 9876187721 Wire voice-tapped facts into entity resolution; fix phraser model config
WriteFactReq gains an optional Subject field (empty = old behavior,
no CoreAPI signature change) and the IntentFact handler now passes the
fact's key as its resolution subject, so voice-tapped facts flow into
the Vikunja #279 enrichment queue automatically.

Also: deploy/mavend.json's phraser was pointed at a 4B model with
n_gpu_layers=99, which OOM'd under memory pressure and left a zombie
llama-server child. Swapped to the 2B Qwen model matching the intended
resident-model size, keeping GPU offload.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018ghELqYhZNLub2TXGMazqA
2026-07-20 12:13:30 +04:00
kami 25357bf267 feat: IPC additions for reminders/events/routines, ack tracking, tool management
- Extend IPC wire protocol: add ListReminders, ListEvents, ListProposedRoutines,
  DismissProposedRoutine, AcceptProposedRoutine IPC methods with request/response
  types. Update wire.go with new message kinds.
- Add ack_sends table (migration #5): tracks sev4 telegram repeat-til-ack
  delivery state with rule name + timestamp, indexed for dedup.
- Add Store.DeleteTool: permanently removes a tool row (for dismissing proposed
  tools), idempotent on missing tool.
- Update tick.go: wire new IPC handlers into daemon tick.
2026-07-10 15:49:10 +04:00
kami 6bab68e96d reminders: add ListReminders IPC + /reminders web page
Store layer: ListReminders returns the n most recent reminders
(newest first). IPC: new MethodListReminders wired through server,
client, and lockedAPI. Web: /reminders page with table of created
time, fire time, status badge, and payload text; empty state with
prompt to ask maven for a reminder. Sidebar entry under Automation.
2026-07-06 22:12:10 +04:00
kami b0932a19df cold-start unlock: key wrap/unwrap, locked-mode daemon, IPC unlock methods
- internal/webauthn/keywrap.go: HKDF-SHA256 + AES-256-GCM WrapKey/UnwrapKey
- internal/ipc/: MethodStoreEncryptionKey/MethodUnlock wire, api structs,
  server dispatch callbacks (WrapKeyFn/UnlockFn), client stubs
- internal/config/config.go: DefaultWrappedKeyPath() method
- cmd/mavend/main.go: locked-mode boot path - detects wrapped key, starts
  locked with lockedAPI stub, wires UnlockFn that opens store + replaces
  CoreAPI on passkey assertion. env-key path stores WrapKeyFn for enrollment.
  make test green (303+, -race)
2026-07-06 13:24:49 +04:00
kami d52f60c54e maven: fix test mocks for CalendarEvents interface (verification)
- Add CalendarEvents method to recordingAPI in auth_test.go
- Add CalendarEvents method to fakeCore in handlers_test.go

Co-Authored-By: opencode <opencode@anthropic.com>
2026-07-06 04:20:16 +04:00
kami cf066bde97 maven: calendar event querying (task 3)
Store: CalendarEvents(ctx, from, to) — filters caldav facts by key date prefix.
IPC: full wiring through interface, server dispatch, and client.
Router: ParseCalendarDate (сегодня/завтра), CalendarEventFormatter (RU reply).
Voice: calendar detection before notes RAG in IntentQuery handler.
Tests: store integration test, date parser tests, formatter tests.

Co-Authored-By: opencode <opencode@anthropic.com>
2026-07-06 04:09:47 +04:00