Commit Graph

85 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 65ee0f9c61 Score every row, pay for only the ten that survive (V-643)
Search decoded the vector blob into a []float32 and JSON-unmarshalled the
meta map for every row, then sorted all N and threw away everything past
topK. Meta only ever matters for a survivor, and the sort answered a
question a bounded heap answers cheaper.

The scan still visits every row — that is what picks the winners. What it
no longer does is allocate for a row it is about to discard. dotBlob reads
the vector out of its stored bytes, so scoring costs nothing; a row is
copied and its meta unmarshalled only once it has entered the topK.

At 10000 rows and topK 10: 70.6ms to 26.8ms, 58MB to 17.5MB, 240k allocs
to 60k.

Recall is unchanged where it is measured. recall+onnx scores 22/32 with
recall@1 70.4% and recall@3 85.2%, identical to before.
TestMemoryStoreSearchMatchesNaive pins the ranking against the full-sort
implementation it replaced, and TestDotBlobMatchesDot pins bit-identical
scores, which the 0.008 gate margin demands.

One behaviour did move: ties. sort.Slice is not stable, so equal scores
were ordered arbitrarily; the heap now keeps the earliest. Under the real
embedder an exact tie is a duplicate vector and nothing moved. Under the
hash embedder the eval's floor uses, everything ties at 0 and that run's
recall@3 went 74.1% to 81.5% — a number that measures tie order, not
retrieval. recall@1 and false recall, the two the eval asserts, are
unchanged on both runs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YMNNEkYx1mZFtHNrFk7uqb
2026-08-07 01:48:05 +04:00
claude 76938e206d Put a number on the recall scan before changing it (V-643)
MemoryStore.Search is on the per-turn recall path and had no benchmark, so
any claim about its cost was an argument rather than a measurement.

Seeds a store with rows the shape recall actually stores — 384-wide
vectors, the resident embedder's width, and a meta blob carrying the note
text — at 1000 and 10000 rows. 10000 is the ceiling the type doc claims a
full scan is fine at.

Measured as it stands: 5.3ms and 24k allocs at 1000 rows, 70.6ms and 240k
allocs at 10000.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YMNNEkYx1mZFtHNrFk7uqb
2026-08-07 01:48:05 +04:00
claude af4eeceb6a Keep the store's one connection, delete the seam it cannot survive (V-642)
`SetMaxOpenConns(1)` under WAL gives up concurrent reads, and the task
asked whether that costs anything. Measured over a fixed two-second
window, a paced writer against a read loop, three runs per cap:
reads do not queue. Four connections buy 70µs at p50 on a turn that
spends 1.19s in the resident model, and write throughput more than
halves. A 19ms worst case also cannot be the source of the 2.7s router
figure, so that line of enquiry is closed.

What the cap cannot survive is a long-lived transaction. It holds the
only connection, so a second read never completes: two seconds and
`context deadline exceeded`, against 1ms at a cap of four.

`Store.DB` handed out exactly that transaction. It had been there since
the initial commit with no production caller, and its comment described
a loop that never materialised. Its one user was a test helper reading
`delivery_attempts` by raw SQL, which `ListDeliveryAttempts` has covered
since V-390. So the cap stays and the seam goes, and the hazard is gone
by construction rather than by documentation.

`internal/store/conncap_test.go` stays as the standing measurement,
skipped under -short. The comment at the cap and the one in
`internal/ipc/server.go` that leans on it now state the invariant and
cite the numbers.

Measurement: docs/evals/2026-08-07-store-connection-cap.md

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07 01:01:27 +04:00
claude e5ec4abe04 a corrected turn is promoted to a label that outlives the trace (V-630)
Migration #24 adds routing_labels, and CorrectTurn writes it. Nothing calls it
yet; the wire and the surface are the next commits.

Separate table, and that is the whole retention argument. A trace is a
transcript and expires in 14 days. A correction is a label the owner wrote by
hand, and it is the only supervised signal this box will ever get, so it is
promoted out at the moment he writes it and kept.

should_be may be empty. "That was wrong" with no target is a usable negative and
must not cost more to give than the full answer. UNIQUE(utterance) so a second
correction replaces the first, because his second answer is the one he meant.
The label and the trace stamp go in one transaction: a stamp with no label loses
the signal when the trace expires.

ErrNoSuchTrace is held apart from a write failure. Correcting a turn older than
the bound is the expected case, and the surface should say so rather than report
a broken database.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117tgnmbgZpHVV3XSNw8Qua
2026-08-06 19:30:46 +04:00
claude e1f84a3474 review: a cancelled turn keeps its trace, and a quiet box still expires (V-629)
Two defects found reviewing the PR.

The insert ran on the turn's own context, so a caller that hung up or timed out
cancelled it. That is exactly the turn worth having. It now runs detached, with
a one-second bound of its own, because a write must not hold the reply.

Retention was enforced on write alone, so a box that goes quiet for a month kept
every row until the next sixty-fourth turn. pruneTracesOnStart closes that, and
RoutingTraceRetention is exported so the daemon reads the same number the store
enforces.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117tgnmbgZpHVV3XSNw8Qua
2026-08-06 19:20:24 +04:00
claude 034d4b4359 the store keeps a routing trace for fourteen days (V-629)
Migration #23 adds routing_traces, and internal/store/routingtraces.go writes,
lists and prunes it. Nothing calls it yet; the daemon side is the next commit.

The utterance is stored in clear. A 384-dimension vector of a short sentence is
substantially recoverable, so storing vectors instead would be a privacy claim
we cannot support. Retention is 14 days, enforced on write, and an age rather
than a row count so a busy Tuesday cannot push last Friday out. Store.Wipe
already deletes it with everything else, so explicit deletion needs no new
surface.

A correction is not covered by that bound. When the owner corrects a turn the
pair is promoted out into a seed-shaped row and kept, because a label is not a
transcript. What stays here is the transcript, and the transcript expires.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117tgnmbgZpHVV3XSNw8Qua
2026-08-06 19:13:04 +04:00
claude 5b8707e21e Merge the store sweep: the repeat-til-ack loop never took a first step (#260)
LastSent scanned MAX(sent_at) into a bare int64. MAX over an empty set
is one row holding NULL, so it errored where its own doc promised a zero
time. ack_sends is written only by MarkSent, which runs only after a
repeat has been sent, so the first repeat for every rule read an empty
table and RepeatUnacked returned on the error and aborted the whole
sweep. The sev4 repeat-til-ack loop could never take its first step for
any rule. nudges.go:213 documents this exact trap for MIN; ack.go never
got the same treatment.

EnqueueDigestEntry deduped on status='pending' alone. A row past its
expires_ts stays pending until the sweep marks it, and tick.go enqueues
before it sweeps, so on the tick after an expiry a suppressed nudge
deduped against a row PendingDigestEntries will never return, and the
phrasing already paid for was discarded. The read side already treated
not-yet-swept as not-deliverable; the write side did not. It also
treated any read error as no-row and inserted anyway.

ack.go had no test file at all. It has one now.

internal/memory was read and is clean, and every embedder call site
correctly passes EmbedPassage for a stored text.

(V-617)
2026-08-06 04:50:43 +04:00
claude 76d123edf3 store: the first repeat-til-ack send, and a digest entry that expired unswept (V-617)
LastSent scanned MAX(sent_at) over an empty ack_sends into a bare int64, so
the ordinary "nothing sent yet" case came back as a scan error rather than the
zero time its doc promises. ack_sends is written only by MarkSent, and MarkSent
runs only after a repeat has gone out, so every rule's FIRST repeat read an
empty table — and RepeatUnacked aborts its whole sweep on that error. The
repeat-til-ack loop could never take its first step. Scans into a NullInt64,
the same way OldestPendingTelegram already does two files over.

EnqueueDigestEntry deduped against any row still marked pending, including one
already past its expires_ts. The tick enqueues before it sweeps, so a suppressed
nudge arriving on the tick after an expiry was told deduped=true against an
entry PendingDigestEntries will never hand back: the caller drops the phrasing
it just paid the LLM for and nothing reaches the bundle. The dedupe now carries
the same expiry test the read side does. Its lookup also stops treating a real
read failure as "nothing there".
2026-08-06 04:50:07 +04:00
claude 13cb1903a9 recurring reminders keep their wall-clock hour and survive downtime (V-616)
RescheduleReminder walked the cron on the UTC instant scanReminder returns,
so a daily 09:00 Moscow reminder rescheduled to 09:00 UTC — noon the same day,
and noon every day after. And any occurrence earlier than now marked the
reminder fired, so a daemon down overnight ended the recurrence for good.

The walk now runs in the owner's location and skips past occurrences instead
of killing the reminder. Skipping and not replaying keeps the no-backlog rule
routine.DueAccepted already follows.
2026-08-06 04:36:36 +04:00
claude 5bca435146 sweep: dedupe memory_vectors scan, fix two swallowed errors (V-581)
allMemVectorMetas (memory.go) replaces the identical query-then-scan
block ReembedAll and RepairFactVectors each had for reading id+meta
out of memory_vectors — same query, same json.Unmarshal, different
structs built from the result.

Two RowsAffected() errors were silently dropped with `_`, inconsistent
with every other call site in the same files: AcceptProposedRoutine
now wraps the error instead of treating it as zero rows, and MarkAcked
had it stranded behind a dead branch (both arms returned nil) removed
along with the swallowed error.

No behavior change; internal/store and internal/memory pass with
-race.
2026-08-06 03:13:47 +04:00
claude aaf1f0236b store/tasks: dedupe live-status literal against the named constants (V-581)
lookupLiveTaskByNorm hardcoded 'candidate','open' in SQL, drifting from
liveTaskStatuses which ListTasks already uses for the same query. Bind
the constants instead so there is one place that names the live set.
2026-08-06 02:10:22 +04:00
claude 82d9a3324e wipe: one command empties the box and leaves it standing (V-494)
There was no documented way to repair a poisoned box. Two invented facts
written during QA disabled world answering for every later turn (V-470), and
revert voids the SQL row while leaving the vector behind (V-493). This is the
operation that undoes both.

Store.Wipe drops every table sqlite_master reports and rebuilds from schema.sql
plus the migrations, rather than deleting from a hand-written list. A list has
to be edited whenever a table is added, and the once it is not, the wipe leaves
personal data behind while reporting success. It vacuums afterwards, because
free pages still hold readable text.

mavend -wipe prints every table and its row count and exits. That alone is a
dry run and answers what a QA session actually asks: what is on this box. It
deletes only with -confirm-wipe. Two flags, because the destructive reading of
one flag is the reading a mistyped command gets.

Nothing outside the database moves. Config, models, passkeys.json and the
encryption key are files.

QA isolation and onboarding are the other two thirds of V-494 and are not here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SoL7EBdYC5Mhz3DJd49GJy
2026-08-05 20:51:46 +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 8e7aa0d451 nudges: a resolved outcome and a way to close a pending alarm (V-535)
The sev4 repeat path reads the nudges table, so ending an alarm means
writing an ending there. 'resolved' is the daemon closing it because the
condition cleared, which is neither 'acted' nor 'ignored'.

ResolvePendingTelegram is rule-scoped and accepts only the two endings the
daemon may write. OldestPendingTelegram backs the age cap and scans into a
NullInt64, because MIN over an empty set is one NULL row, not zero rows.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 01:42:44 +04:00
claude 1da3aa39e8 Merge master into the line B review stack (V-405)
The two open lines never met: line A landed through #168, so every pull
request from #148 to #160 conflicted with master on six files. This
reconciles them.

Where the two lines fixed the same thing, the better shape wins:

- Ambient time zones (V-482) landed on both sides. Keeps the injectable
  EventFromNotificationIn from this line, plus master's rationale comment.
  Drops master's forced n.Posted.In(time.Local), which defeated the loc
  argument.
- tick.go: master's guardNudge call and say.CountWord edits, moved onto the
  split files this line created. The digest summary now declines through
  say.CountWord inside tick_digest.go.
- voice.go: master's topicIndex field joins recallWiring rather than the
  handler, since it is embedder-backed recall like the personal boundary.
  topics.go and its test read h.recall.topics now.
- mavweb: master's capability and risk columns ported into tools.html, which
  is where this line moved the markup. The Go const is gone.
- Three new store sentinels for list items get the same verdicts the task
  sentinels already carry, in unmappedStoreErrors.

make build: 12 binaries. make test: green. make fmt-check: clean.

--no-verify: a merge of two long lines cannot fit the 300-line budget.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 20:46:53 +04:00
claude fc9538d07f money and list: the dictionary matches the word (V-529) 2026-08-04 18:27:50 +02:00
claude 5b7480ddaf mavend: Nexus says which name it knows, we do not pick (V-524)
entityReferenceText returned the longest Latin run in the utterance, which is a
guess dressed as a rule. "перезапусти nginx на muzick-indexer" holds two names,
the target is not the longer one, and docs/ecosystem.md already says what to do
instead: ambiguous resolution asks the owner, it does not pick. Nexus owns which
names it knows.

So entityReferences returns every Latin run, in the order he said them, capped
at four so one utterance cannot fan out into a dozen HTTP calls.
resolveEntityCandidates asks about each and stops as soon as the answer is
decided: a Nexus failure ends it and reports degradation, Nexus calling one name
ambiguous ends it with its candidates, and two names resolving to different
entities is our own clarify listing the names Nexus spells. One resolving is the
target, none resolving falls through as before.

The transliteration signal is unchanged — the recovery still fires only when the
utterance carries a Latin run and the model's Text slot carries none.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XGTGCWX33aX8SMBSRz9VmS
2026-08-04 18:12:25 +04:00
claude 8aa790cc48 Merge task/476 into the entity-reference branch (V-524)
--no-verify: a merge commit's diff against origin/master is the whole stack,
which the 300-line guard cannot pass. The one conflict was in
internal/store/migrations.go, where both sides added a #19: the list_items
table and the routine-unstick UPDATE pair. Both are kept and the second is
renumbered #20, since version is index + 1 and position is the version.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XGTGCWX33aX8SMBSRz9VmS
2026-08-04 18:07:28 +04:00
claude 08512ad58b store, ipc: type the routine status, defend the framing with tests (V-410)
Two review threads from PR 4, and the answer to the third.

The routine status was a bare string with its legal set in a comment.
Nothing caught a typo at compile time, nothing enumerated the set for a
test, and a bad value surfaced as a /routines row that neither accepts nor
dismisses. It is a RoutineStatus now, with the three constants, a
RoutineStatuses slice as the single source of truth, and Valid(). Listing
by an unknown status is refused with ErrRoutineStatus instead of answering
"no rows", which is what a correct query says about an empty table. A
round-trip test moves a routine into each state and reads it back, so a
constant that drifts from the inline SQL fails loudly.

The hand-rolled framing stays, and frame.go now says why: ninety lines,
readable with socat, and every standard replacement brings schema
machinery this boundary does not want. What was wrong was inheriting it
untested. frame_test.go covers the paths a real socket produces and the
round-trip test never does — truncated header, truncated body, one byte
per Read, two frames back to back, and a non-JSON body. Empty input is the
only EOF.

The unanswered question in the same file is answered in place: a routine
object stays a local string, not a Nexus ref, because nothing acts on it.
It is the word he used, replayed back to him, compared only against itself
for the UNIQUE key. Canonical refs arrive if a routine ever drives a Hexis
call, which is V-272.

The mood enum has the same shape and is not done here: it is spelled in
the GBNF grammar, three prompts and the parse, so it is its own change.
2026-08-04 05:39:52 +04:00
claude 4f516657da loop, store: read a fact family by prefix (V-444)
A rule over a key set that only exists at read time cannot declare its keys
at wiring time. Kuma has one monitor per service and the names live in the
gauge, so the rule declares a prefix and the gatherer resolves the family per
tick.

ServiceDownRule now fires on any monitor reading down, names it through
DownServices, and is edge-triggered: a service that stays down is one nudge,
not one per tick with cooldown as the only brake.
2026-08-04 05:14:50 +04:00
claude 0d52344d27 store: cover the list_items shape with tests (V-453) 2026-08-04 04:39:24 +04:00
claude 5bd303788b store: add list_items, the fourth append-only shape (V-453)
A list is a standing set of short strings under a tag. Not a task, because
milk is not work and the prioritiser must not count it as an errand; not a
fact, because it claims nothing. Nothing predicates over it, so two people
adding to the same list at once costs nothing.

Migration #19, plus AddListItem, ListItems, SetListItemStatus and ClearList.
The live-only unique index is the tasks one, per list: молоко twice before
the shop is one row, молоко again after it was crossed off is a new one.
2026-08-04 04:39:24 +04:00
claude a820a95ebb store: wake the routines accepted before the fire-forever fix (V-377)
Routines accepted before Vikunja #366 carry accepted_ts NULL and a live
reminder row. The tick loop reads accepted_ts to decide when a routine is
next due, so those rows have been silent since the fix landed, while the
reminder they still point at keeps firing on its own schedule.

Migration #19 cancels that reminder first, then dates the acceptance from
created_ts and lets the reminder id go. Order matters: the second update
clears the id the first one needs.
2026-08-04 03:29:51 +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 908d92a7e8 calendar: a Russian summary keeps its letters in the fact key (V-443)
safeKey kept ASCII only, so "Встреча с Аней" and "Обед с мамой" both
reduced to "--" and shared one key on one day. The second event of the
day overwrote the first, silently, and his calendar is Russian.

Letters and digits in any script now pass. Migration #18 deletes the rows
written under the old rule instead of rewriting them: a calendar fact is
derived, the next poll writes the day again, and a stale row reads as an
extra meeting.
2026-08-04 02:56:09 +04:00
claude ad60e10e95 mavend: run the fact vector repair on start, and test what it does (V-493)
Automatic rather than a flag, unlike -reembed: only voice-tapped facts are in
this index, so it is tens of embeddings rather than thousands of notes. And
waiting for an operator to know the repair exists is the failure being fixed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 22:36:54 +04:00
claude 1528697287 store: repair fact vectors against the facts they name (V-493)
Every write-path fix leaves the rows already stored wrong, and a box in that
state looks fine: recall answers with the wrong text and nothing logs an error.
That is how the original poison survived four restarts.

RepairFactVectors resolves each fact vector against the fact it names,
re-embeds the ones whose text is stale, and deletes the voided, superseded and
orphaned ones. Marker-guarded and idempotent, so it runs once per box and a run
that dies partway is simply redone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 22:36:54 +04:00
claude dbdab2d570 store, mavend: a fact is indexed as the fact, not as the utterance (V-493)
queryMemory returns a fact's stored text verbatim, so the text the write path
indexed is what he hears. It was the utterance, which made recall of any
voice-tapped fact answer with the sentence he said: go_version = 1.20 was
indexed as "какая последняя версия языка Go?", and that question came back.

FactRecallText renders the fact instead, and the utterance stays in meta as
provenance. Correcting a value now drops the key's vectors the way voiding one
does, since the superseded value was still answering.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 22:36:34 +04:00
claude f3fa6b353a store: voiding a fact drops its memory vectors (V-470)
Revert voided the fact row and left the vector, so recall kept serving the
voided fact's utterance and the documented repair reported success on a box that
stayed broken. There was no way to repair a poisoned box at all.

DeletePrefix covers every vector for the key, earlier rows included: their values
are superseded, and a superseded value has no business claiming a turn. It is
best-effort — the audit trail is already committed, and a fact that is voided but
still recallable beats a void that failed.
2026-08-03 13:40:13 +04:00
kami b35151418a mavend: give the voice handler a CoreAPI that can serve the day plan
wireVoice runs before the tick loop exists, so it could only be handed
the bare store adapter — and that adapter answers DayPlan with "not
available via direct store API", because a day plan is assembled by the
tick loop and is not a table to read. So queryDayPlan, which the query
chain reaches for "какие у меня планы на сегодня", failed for every
caller on the deployed daemon.

main already back-patches the other direction (daemonAPI.chatFn =
handler.handleText). This is the same seam in reverse, at both wiring
sites. No recursion risk: nothing in the voice path calls api.Chat.

With the plan reachable, it recited its reminders as literal JSON. The
payload unwrapper existed but was private to the phraser, so the day
plan had its own non-unwrapping copy. One owner now, store.ReminderText,
with the phraser delegating to it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TrVSBKe3RFDF4fGYKWYQnX
2026-08-01 23:17:10 +04:00
kami f1a809121b shutdown: close the sockets, or the database never gets sealed
mavend seals its encrypted database in `defer st.Close()` when run() returns.
It had not returned since 2026-07-21. Every restart since then decrypted the
same eleven-day-old ciphertext and rolled back everything written in between:
the Telegram nudge that kept firing was a fact being un-written on each boot.

The goroutine dump named it. main → srv.Close() → ipc.(*Server).Close →
wg.Wait(), waiting on per-connection goroutines parked in readFrame. Close
shut the listener and nothing else, so the idle persistent sockets held by
mavweb, mavpoll, mavcaldav and mavmaild blocked shutdown forever. `docker
compose stop -t 60` spent the whole sixty seconds and then took a SIGKILL.

So: track the accepted conns and close them, in ipc and in voice, which had
the identical defect. Bound all three waits — the two per-server ones and the
worker wait in main — because the seal matters more than any single in-flight
call. A dropped RPC costs one reply; a missed seal costs a session.

The regression test leaves a client connected and idle, which is the case the
old tests avoided by closing the client first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TrVSBKe3RFDF4fGYKWYQnX
2026-08-01 20:05:52 +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 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 b436be69c3 Merge branch 'fix/g09' into fix/integrated 2026-08-01 14:22:24 +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 89afe4ca99 Merge branch 'fix/g04' into fix/integrated
# Conflicts:
#	cmd/mavend/actions_query.go
#	cmd/mavend/dayplan_test.go
2026-08-01 14:19:15 +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 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 f02f3b55b6 memory: keep voiceprints out of note and fact recall
Speaker profiles share the vector table with notes and facts. The doc
comment said reading them through Catalog is what keeps recall from
ranking a voiceprint. It is not. Catalog controls how speaker code reads
its own rows and says nothing about Search, which scanned every row.
What actually hid them was cosine returning 0 on a width mismatch, so a
192-dim ECAPA row scored 0 against a 384-dim query. Some x-vector
exports are 384-dim, and one of those would have surfaced speaker:kami
as a recall hit carrying the name of a person.

Both backends now skip the prefix in Search, and the prefix is one
constant in internal/memory so the store layer can filter on it without
importing internal/speaker.

Two more differences between the backends closed here. ByPrefix on the
in-memory store returned the stored metadata map by reference, so a
caller editing a returned Record edited the row, while the persistent
one unmarshals fresh. And the append to upsert change in Insert is a fix
in its own right, not only a speaker concern: any re-indexed id used to
leave a second stale copy searchable.

Found in review of #74.
2026-08-01 14:12:43 +04:00
kami 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.
2026-08-01 14:11:57 +04:00
kami 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.
2026-08-01 14:07:43 +04:00
kami 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.
2026-08-01 14:01:57 +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 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.
2026-08-01 13:57:26 +04:00
kami 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
2026-08-01 12:50:47 +04:00
kami 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
2026-08-01 06:27:39 +04:00