Turn a mail into task candidates, and into nothing else (#246) #64
Closed
claude
wants to merge 1 commits from
overnight/email-extract into overnight/email-imap
pull from: overnight/email-extract
merge into: kami:overnight/email-imap
kami:master
kami:task/725-capability-ledger-and-empirical-baseline
kami:task/692-heads-path-may-equal-model-path-and-noth
kami:task/694-staticcheck-and-deadcode-are-still-not-i
kami:task/682-go-1-25-5-and-x-text-0-14-0-carry-20-rea
kami:task/674-caveats
kami:task/673-mavgpud-serves-the-model-to-the-whole-la
kami:task/487-capture-device-doc
kami:task/487-capture-device
kami:task/487-wake-word-deploy
kami:task/487-wake-word-threshold
kami:task/487-wake-word-stage-two
kami:task/671-mavwaked-registers-as-a-voice-consumer-i
kami:task/670-cut-claude-md-to-200-lines
kami:task/515-deploy-mavwaked-workpc
kami:task/669-prune-claude-md
kami:task/668-e4b-phrasing
kami:task/668-title-capital
kami:task/668-kiwix-answers-a-question-it-cannot-answe
kami:task/666-only-a-stage-0-grammar-may-take-the-pers
kami:task/487-mavwaked-has-no-wake-word-only-an-energy
kami:task/486-deploy-the-workstation-transcriber
kami:task/486-move-stt-and-tts-to-the-workstation-wher
kami:task/665-crisperwhisper-2-russian
kami:task/664-routing-heads-in-go
kami:task/662-usage-harness-source-badge
kami:task/661-post-merge-usage-rerun
kami:task/661-routing-heads-step-3-train-the-multi-hea
kami:task/660-router-prompt-destination
kami:task/659-destination-fixture
kami:task/655-query-source-is-a-routing-decision-made
kami:task/654-a-pending-clarify-has-no-way-out-neither
kami:task/654-week-of-usage-eval-docs
kami:task/649-needs-kami-telegram-is-the-only-reach-an
kami:task/643-memorystore-search-decodes-and-unmarshal
kami:task/641-two-maps-grow-for-the-process-lifetime-w
kami:task/644-mavcaldav-is-built-documented-as-running
kami:task/642-the-store-caps-sqlite-at-one-connection
kami:task/647-factenrichmentworker-walks-the-pending-q
kami:task/646-v-637-follow-up-telegram-intake-has-no-d
kami:task/638-no-deadline-survives-the-turn-path-from
kami:task/637-inbound-telegram-turns-and-corrections-f
kami:task/636-correcting-a-turn-from-telegram-and-from
kami:task/634-an-act-alias-resolves-the-verb-but-not-t
kami:task/630-one-gesture-correction-on-chat-v-628
kami:task/629-persist-the-routing-trace-and-record-it
kami:task/631-mode-inventory-written-from-the-handlers
kami:task/586-defaultfactparser-uses-hand-written-russ
kami:task/633-reconcile-the-seed-labels-with-the-handl
kami:task/627-reminder-verbs-has-no-alarm-verb-so-an-a
kami:task/626-the-classifier-seeds-teach-an-older-inte
kami:task/546-route-with-a-fine-tuned-e5-small-instead
kami:task/586-measure-the-fact-parser
kami:fix/gofmt-ecosystem-acts
kami:task/584-media-store-a-failed-write-leaks-its-bud
kami:task/518-no-write-path-for-a-backdated-event-so-t
kami:task/287-qa-voice-session-quality-polish
kami:task/492-qa-plan-reconcile
kami:task/530-sweep-tail-four-files-the-russian-sweep
kami:task/405-score-how-often-a-real-utterance-reaches
kami:task/529-money-and-list-pick-a-mechanism
kami:task/528-sweep-tail-the-three-files-on-467
kami:task/527-embedder-open-set-phrasings-stop-being-r
kami:task/526-morphology-a-dictionary-answers-the-gram
kami:task/525-lexicons-the-finite-russian-sets-move-to
kami:task/524-entity-reference-ask-nexus-about-every-l
kami:task/523-risk-tiers-take-hexis-s-tier-for-a-hexis
kami:task/521-review-pr-111-query-strings-declension-h
kami:task/491-llama-server-core-dumps-on-every-sigterm
kami:task/479-bug-an-unconfigured-capability-does-not
kami:task/467-bug-spoken-task-capture-is-dead-the-rout
kami:task/463-deploy-mavwaked-and-mavenclient-run-nowh
kami:task/480-hearing-no-shipped-client-can-start-a-re
kami:task/432-ambient-calendar-intake-is-fragile-and-p
kami:task/431-board-surface-maven-holds-the-work-board
kami:task/433-reactivehandler-has-30-fields-and-is-pas
kami:task/371-swap-the-embedder-for-an-asymmetric-retr
kami:task/408-review-31-07-split-the-30-method-coreapi
kami:task/410-review-31-07-hand-rolled-string-enums-st
kami:task/423-review-pr50-split-internal-ipc-server-go
kami:task/422-review-pr50-split-cmd-mavend-tick-go-860
kami:task/409-review-31-07-finish-moving-mavweb-markup
kami:task/482-ambient-ingest-reads-a-notification-s-ti
kami:task/444-kuma-a-fact-per-monitor-so-she-can-name
kami:task/452-capability-model-homelab-docker-restart
kami:task/449-destructive-confirm-policy-risk-tiers-no
kami:task/453-grocery-list-items-table-fourth-append-o
kami:task/399-run-the-persona-checks-inside-the-daemon
kami:task/448-bounded-follow-up-state-pending-candidat
kami:task/455-conversation-repair-name-the-misroute-co
kami:task/454-go-mod-tidy
kami:task/458-pronunciation-dictionary-for-piper
kami:task/456-command-history-read-only-query-over-exi
kami:task/457-clarification-templates-for-the-router-s
kami:task/474-query-source-ordering-feeds-and-calendar
kami:task/469-reminders-spelled-out-times-fail-the-bod
kami:task/475-bug-the-praxis-attention-capability-is-u
kami:task/481-bug-a-transient-complaint-is-stored-as-a
kami:task/476-bug-the-router-transliterates-latin-enti
kami:task/385-decide-whether-a-parked-clarify-question
kami:task/377-backfill-routines
kami:task/421-weather-geocoder
kami:task/390-no-read-path-for-delivery-attempts
kami:task/386-recall-fixture-filler-note-ids
kami:task/473-bug-morning-item-has-no-required-flag
kami:task/465-bug-make-simulate-routes-with-an-empty
kami:task/467-bug-spoken-task-capture-is-dead
kami:task/466-bug-a-pending-clarify-is-global-so-one-u
kami:task/468-bug-pattern-detect-has-no-minimum-interv
kami:task/462-bug-checkfeminine-flags-second-person-ma
kami:task/443-safekey-drops-cyrillic-so-russian-calend
kami:task/471-bug-agendaquerygrammars-covers-today-but
kami:task/383-slottext-in-clarify-answer-would-clobber
kami:task/323-qa-phraser-coverage-is-65-3-but-the-llam
kami:task/498-bug-and-x-reach-the-model-with-no-determ
kami:task/506-strings-family-6-summaries-and-reports-i
kami:task/504-strings-family-4-act-and-smart-home-repl
kami:task/503-strings-family-3-query-answers-and-gaps
kami:task/502-strings-family-2-capture-acknowledgement
kami:task/501-strings-family-1-phrasing-fallbacks-into
kami:task/397-phrasechat-and-phrasequery-hide-model-fa
kami:task/396-the-reply-path-can-t-be-tested-llmreplie
kami:task/496-recall-a-cross-language-question-loses-i
kami:task/495-bug-x-escapes-the-personal-boundary-and
kami:task/499-llama-server-holds-7-9gb-rss-for-a-1-1gb
kami:task/470-bug-a-question-writes-invented-knowledge
kami:task/493-bug-the-memory-index-stores-the-raw-utte
kami:task/490-name-the-gap-world-questions-through-the
kami:task/485-run-the-big-model-on-the-workstation-wit
kami:task/489-workstation-deploy-mavgpud-on-workpc-and
kami:task/488-workstation-a-supervisor-that-keeps-llam
kami:task/483-docs-offload-design
kami:task/483-design-offload-ml-to-the-workstation-kee
kami:task/459-docs-refresh-the-qa-plan-against-the-liv
kami:task/446-doc-reorg-tier-the-tree-retire-the-three
kami:fix/367-voice-parks-routine-accept
kami:task/365-dialogue-slots-and-router-slots-are-hand
kami:task/364-snooze-does-nothing-at-runtime-the-gate
kami:task/447-retire-progress-md-the-backlog-and-the-f
kami:task/445-session-workflow
kami:overnight/eco-versioned-traces
kami:overnight/eco-entity-refs
kami:overnight/eco-degraded-suite
kami:overnight/netscan
kami:overnight/smarthome
kami:overnight/replay-simulator
kami:overnight/event-envelope
kami:overnight/coldstart-unlock
kami:overnight/voice-barge-in
kami:overnight/stt-golden-audio
kami:overnight/senses-speaker
kami:overnight/senses-hearing
kami:overnight/senses-media-vision
kami:overnight/mcp-tools
kami:overnight/mcp-client
kami:overnight/self-update
kami:overnight/model-swap
kami:overnight/web-crawler
kami:overnight/rss-feeds
kami:overnight/email-poller
kami:overnight/email-imap
kami:overnight/money-zenmoney
kami:overnight/task-priority
kami:overnight/task-capture
kami:overnight/behavior-profile
kami:overnight/day-plan
kami:overnight/ambient-calendar
kami:overnight/local-calendar
kami:overnight/memory-eval
kami:overnight/proactive-proposals
kami:overnight/split-voice-quiet
kami:overnight/nginx-maven-block
kami:overnight/stepup-chat-surface
kami:integration/small-batch
kami:docs/fix-drift
kami:fix/ru-wording
kami:integration/jul31
kami:overnight/resident-1.7b
kami:overnight/nudge-templates
kami:overnight/kiwix-rewrite
kami:overnight/eval-writeup
kami:overnight/fix-truncation
kami:overnight/kiwix-client
kami:overnight/ru-prompts
kami:overnight/external-data
kami:overnight/phrasing-grammar
kami:overnight/talk-eval
kami:overnight/prompt-context
kami:overnight/prompt-address
kami:overnight/eval-label-kill
kami:overnight/delivery-boundary
kami:overnight/address-check
kami:overnight/system-replies-pr
kami:overnight/clock-intent-pr
kami:overnight/embedder-backfill-pr
kami:overnight/embedder-marker-pr
kami:overnight/note-recall-pr
kami:overnight/thinking-off-pr
kami:overnight/dialogue-persist-pr
kami:overnight/persona-2p-pr
kami:overnight/clarify-expiry-pr
kami:overnight/clarify-rework
kami:overnight/phrasing
kami:overnight/bakeoff
kami:overnight/recall-margin
kami:overnight/router-on
kami:overnight/slot-extract
kami:overnight/embedder-e5
kami:overnight/router-refusal
kami:overnight/eval-rerun
kami:overnight/eval-harnesses
kami:overnight/eval-rerun-base
kami:overnight/fmt-gate
kami:overnight/routines-fire
kami:overnight/router-prompt
kami:overnight/away-leak
kami:overnight/recall-eval
kami:overnight/snooze-works
kami:overnight/clarify-wiring
kami:overnight/delivery-tests
kami:overnight/routine-accept
kami:overnight/llm-router-flag
kami:overnight/clarify-data-layer
kami:overnight/loop-rule-tests
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Delete Branch "overnight/email-extract"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Second of the email-reader chain (Vikunja #246). Extraction plus the core-side seam; the poller daemon is the next branch.
What changed
internal/email/extract.go—Extractor.Extractasks the resident model, under a GBNF grammar, for ≤3 candidates{text, due}. Junk and empty messages never reach the model. Repeats and impossible dates are dropped.internal/ipc— newMethodIngestMail+IngestMailReq/Resp, client method, and aServer.IngestMailFnhook (same pattern asStepUp/UnlockFn; not a CoreAPI method).internal/auth/policy.go—MethodIngestMaillisted explicitly at AuthRead, with the reasoning: it can only produce candidates.internal/config—emailblock (max_tasks,timeout). No host, no credential, no mailbox: core does not connect to IMAP.cmd/mavend/mail.go— the intake: extract, thenCaptureTaskwithStatus: candidate,Source: "email:<mailbox>",Evidence: <subject>. Wired on both startup paths (boot and passkey unlock).Why this shape
Everything mail can produce is a candidate task. No reminder (a reminder fires — a misread mail must never be able to speak), no fact (Maven would later recite it as true), no calendar event, no note. The due date is stored where no scheduler reads it.
Core owns extraction because llama-server lives in core's process; the credential stays in the reader daemon, so core never sees it. The IPC hook is nil unless there is both an
emailblock and a llama-server phraser — an unconfigured core answersErrUnknownMethodrather than silently ignoring a reader. No keyword fallback: "the subject became a task" is a mailbox rendered as a to-do list.Verified
make buildandmake test(go test -race) green. New tests: extraction (grammar used, empty array is normal, junk skips the model, caps, repeats/bad dates dropped, parse error hides mail text), intake (candidate status + source + evidence + due, idempotent on a re-read, junk writes nothing, no reminder is ever created, off without config), and the IPC hook (ErrUnknownMethod when unwired, round-trip when wired).Vikunja #246
The bound is real and it is structural, not a promise in a comment.
Extractorholds no store and no writer, so it cannot persist anything.mailIntake.ingesthardcodesstore.TaskCandidateand calls onlyCaptureTask, so no fact, reminder or nudge is reachable from a parsed mail. The double gate is the right shape too: noemailblock or noLLMPhraserleavesIngestMailFnnil, andMethodIngestMailthen answersErrUnknownMethodrather than succeeding quietly.On the two boundaries you asked about, both hold in this PR. Core never learns the IMAP host, user or password.
EmailConfigcarries onlymax_tasksandtimeout, andIngestMailReqcarries only mailbox name and message text. Mail text also has no path to an external engine.Extractortalks tollm.New(lp.BaseURL(), ...), which is the local llama-server. The only outbound readers in the tree areinternal/crawlandinternal/kiwix, and this file touches neither.wireMailIntakeon the unlock path also picks up the reassignedstandphr, so it binds the unlocked store and not the placeholder.Three findings.
1. Extraction and the voice turn share one llama-server slot, and extraction is allowed two minutes.
startLlamaProcpasses-m -c -ngl --no-webuiand no-np. llama-server runs one slot, so requests queue.DefaultEmailTimeoutis 2 minutes, andmailIntake.ingestspends it on a Thinking 1.7B reading up toMaxBodyBytesof 4000 characters with a 512-token budget.Walk it. A mail arrives. mavmaild calls
ingest_mail. The model starts extracting. He says something to Maven three seconds later. The router'sCompletequeues behind the extraction. Router p50 is 2.7s on this box, so the turn now takes as long as the extraction has left. The router degrades to the classifier cascade on error. That is the designed floor, so he gets 36.8% routing accuracy while his mail is being read. The phraser has no such floor and just waits.PR 65 makes this worse, since the poller sends
Maxmessages back to back and each is its own ingest call. A first poll against a mailbox with 40 unseen messages serialises 40 extractions ahead of every voice turn.Nothing in this PR is wrong in isolation. The gap is that mail extraction is a background job competing with a foreground one for a single-slot resource. There is no priority, no queue depth limit and no backpressure. Give llama-server a second slot. Or have
ingestdecline while a turn is in flight. Or bound the daemon to one extraction per N seconds.2. The extraction timeout also bounds the capture writes, so a slow model loses the candidates it just produced.
Same
ctx. The config comment callsTimeouta "per-message extraction budget", and it is not. Say extraction returns at 119 seconds of a 120-second budget. The firstCaptureTaskthen gets one second against an encrypted store, and the third gets none.ingestthen returnsmail intake: capture: context deadline exceededwith a partially populatedresp. The model did the work, the answer was good, and it is dropped. Derive a fresh context for the write loop, or scope the timeout to theExtractcall only.3.
MethodIngestMailis the firstAuthReadmethod that spends the resident model.The policy comment argues the rung from what the method writes, and that argument is sound. Candidates are the cheapest possible wrong outcome. But the rung is also being asked to cover what the method costs, and that side is new.
AuthReadrequires nothing. Any process that can open the socket can post arbitrary text asingest_mail. That pins the resident model for up to two minutes per call, repeatedly. Finding 1 turns that into a mute assistant. Every otherAuthReadmethod is a store read or a bounded write.Related,
req.Mailboxgoes into the source string unvalidated:An empty mailbox gives
source = "email:", and an arbitrary string gives an arbitrary provenance under theemail:namespace. The source vocabulary is what the loop's rules trust. Validate it against the same shape the other sources use, and reject empty.Smaller notes.
cfg.Email.MaxTaskswhileNewExtractorhas already normalised it. With"email": {}in mavend.json the daemon logsmail intake: enabled (max 0 candidates per message, timeout 2m0s)and then allows three. Log the normalised value.parseCandidatessays it tolerates "leading reasoning before the array", butextractGrammarpins the first token to[. The tolerance cannot be exercised throughExtract. Harmless, except the comment tells the next reader that thinking output is expected here when the grammar forbids it.newMailIntakedisables mail on a failedphr.(*phraser.LLMPhraser)assertion. That is the correct default. But the log line says "configured but no llama-server phraser" for what could equally be a wrapped phraser. If anything ever wraps the phraser, mail turns itself off and the log points at the wrong cause.ingestreturnsIngestMailResp{}for "the model ran and found no task" andIngestMailResp{Skipped: true}for "we did not ask". The distinction is right. State in theIngestMailRespdoc thatCreated == 0 && !Skippedmeans the model was consulted. PR 65 has to decide whether to mark such a UID seen.tasks.text. Evidence is subject-only, so the "no copy of his mailbox" claim holds today. It stops holding the moment anything assembles a context block from live tasks. Worth a line in the package comment so the next person adding task context to a prompt sees the constraint.Landed on master. The stack was one linear chain, so #84 carried every commit from #50 up, and master now contains this branch in full. Merging this PR on its own is an empty diff, so it is closed rather than merged. The review findings for it were fixed in the 2026-08-01 pass and are on master as commits on the stack tip, not on this branch.
Pull request closed