Vision: store and describe images through a shared media intake (#252) #72
Closed
claude
wants to merge 1 commits from
overnight/senses-media-vision into overnight/mcp-tools
pull from: overnight/senses-media-vision
merge into: kami:overnight/mcp-tools
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/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-extract
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/senses-media-vision"
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?
What changed
internal/media— the intake all three senses share. A content-addressed blob store ondisk (sha256 key, sidecar JSON metadata, 0700/0600), plus image decode / alpha-flatten /
downscale / JPEG re-encode with no new dependencies.
Prune()enforces retention.internal/vision—Providerseam with one method, aDisabledfloor so no call siteneeds a nil check, and
LocalProviderspeaking the OpenAI multimodal shape at a llama-server.Intakestores the blob before describing it, andRerun(id, question)describes a storedblob later.
Config — new top-level
mediaandvisionblocks, both absent by default.IPC —
describe_image,AuthRead, hook-gated: nil hook ⇒ErrUnknownMethod, the same"off unless configured" shape
ingest_mailuses.cmd/mavend/vision.go— wiring, plus the hourly retention prune loop.Why it looks like this
Vision needs a model this box does not have. Checked
/mnt/hdd1/llms: sixteen ggufs, alltext-only, no
mmprojanywhere, and the resident Qwen3-1.7B is text-only by construction. Sothe describing half is BLOCKED on a model download and what ships is the intake, the
storage, the config seam and the provider — tested against a fake server.
Intake.Acceptstores first and describes second precisely so that today's state degrades to "it's kept, I
can't read it yet, here is the id" instead of losing the image.
The plan's
RemoteProviderstep is refused. It called for "an OpenAI-compatible vision APIendpoint". CLAUDE.md's surviving constraint is no cloud model, inference stays on the box, and a
photo of his flat is the worst possible exception.
vision.NewLocalvalidates the endpoint atconstruction: loopback, private IP, or
localhost. A bare hostname is refused too — it couldresolve anywhere.
Privacy invariants, enforced not just documented: blobs never leave the box, are never search
input, are never embedded, and are pruned on a loop. Only the derived description becomes a
note, and only when the caller passes
save_note.How it was verified
make buildandmake testboth exit 0. New tests: 27 acrossinternal/media(dedupe keepsfirst-seen time so a re-sent photo cannot outlive retention; path-traversal ids refused;
permissions; prune),
internal/vision(every non-private endpoint form refused; data-URI wireshape;
reasoning_contentfallback; store-survives-describe-failure), andinternal/config.Not verified against a real vision model — there isn't one. That is the QA step on the task.
Vikunja #252
The shape of
internal/mediais the good part. Sidecars instead of anothersqlite table means the store reads with
lswhen something goes wrong. Blobsstay out of the encrypted database.
validIDguards every path built from acaller-supplied id.
Putkeeps the first-seenCreated, so re-sending the samephoto hourly cannot hold it past retention. The plan asked for a cloud vision
call. Refusing outright, and writing the refusal into the package comment, is
the right answer.
The findings below are all about one gap. The invariants are stated as prose in
package comments, and three of them are not what the code does.
1. One PNG OOMs mavend, and it only needs AuthRead
PrepareImagesniffs, then decodes the whole image, thenflattenAndScaleallocates
image.NewRGBA(image.Rect(0, 0, sw, sh))at the source dimensionsbefore it scales anything. Nothing anywhere checks pixel count. The only cap is
media.DefaultMaxBytes, 64 MiB, and that is on the compressed input.Walked through. A 20000x20000 PNG of flat colour compresses to a few hundred
kilobytes, well under the cap.
png.Decodeproduces an NRGBA of 400 millionpixels, about 1.6 GB.
flattenAndScalethen allocates a second RGBA of the samedimensions, another 1.6 GB, and composites into it. That is 3.2 GB of live heap from one request. On a laptop. In the process that
owns the database and the socket.
MaxDim: 896never gets a chance to help, because the downscale targetis only allocated after the full-size one.
image.DecodeConfigreads only the header and givesWidthandHeightforall three formats. Reject on
w*hover a few tens of megapixels beforedecode, and makeflattenAndScalewalk the source throughsrc.Atratherthan materialising a full-size RGBA first. The flatten-onto-white step does not
need its own full-size buffer.
2. The method exists whenever
mediais configured, not when vision is onThree comments say otherwise, including the wire contract.
From the
cmd/mavend/vision.goheader:From
ipc.DescribeImageReq:From
Server.DescribeImageFn:newVisionIntakereturns a non-nil intake withvision.Disabled{}wheneverkeeper != nil, and its own doc comment argues for exactly that. So one commentin this diff contradicts the other three, and the code follows the minority.
The consequence is not cosmetic. The package comment says this box has
mediaset and no vision model. In that state
MethodDescribeImageis live at AuthReadand does nothing but write attacker-chosen bytes to disk. No description is
produced. Every enrolled module can call it.
Pick one. Storing without describing may well be useful. If so, say it in
ipc.DescribeImageReqandServer.DescribeImageFntoo. Those two are whatanother surface reads before deciding whether to send. Otherwise gate on
cfg.Vision.LooksAtImages().3.
SaveNotewrites to memory at AuthRead, under a source no module ownsThe policy comment argues the rung by what the method cannot do. "It cannot
write a fact, set a reminder, or touch the tool allowlist." It can write a note.
writeNoteembeds the description withEmbedPassageand stores it undermedia:image:<id-prefix>. An embedded note is recall corpus. It comes back in alater turn as something she knows.
That is the property
AuthWriteexists to protect. The comment onAuthWritestates it plainly: "a module only writes sources it owns", so a compromised
poller cannot forge a source.
media:image:*is owned by no enrollment, andDescribeImagelets any AuthRead caller write it.MethodIngestMailsits onthe same rung and its justification holds, because its output lands on a review
page. A note does not land on a review page.
The narrow fix is to require AuthWrite when
SaveNoteis set.Canalreadyre-parses params for
WriteFact, so the machinery is there. Leaving thedescription-only call at AuthRead is defensible on its own.
Separate but adjacent: the note is a small VLM's guess, stored as plain note
text with no marker. The file header says a 1.7B-class guess "is not a fact worth carrying around".
Then
SaveNotecarries it around, in the same shape as something he told her.4. Retention does not cover a blob whose sidecar is missing
PruneiteratesList("").Listwalks for.jsonfiles and drops anythingreadMetarejects. So a blob whose sidecar is corrupt, or absent, is invisibleto
Pruneand stays on disk forever.Putproduces exactly that state. It callswriteFile(blobPath, data)firstand
writeMeta(metaPath, b)second. A full disk, a permission change, a crashbetween the two, and the bytes are on disk with no sidecar.
Putreturns anerror, the caller reports failure, and an image nobody knows about is now
permanent.
The package comment calls this the point of the whole package:
Write the sidecar first. Better, have
Prunealso sweep blob files that have noreadable sidecar and are older than retention. That also collects whatever a
previous version leaked.
Smaller notes
LocalProvideruses a barehttp.Clientwith the default redirect policy.checkPrivatevalidates the configured literal at construction and nothingchecks a hop. A 302 from the local llama-server sends the image, as a data URI
in a POST body, to whatever the redirect names. The package comment says "No provider in this
repo may upload one". The private check exists because "a photo of his flat is
the single worst thing to make an exception for". One line,
CheckRedirect: func(...) error { return http.ErrUseLastResponse }, makesthe claim true. While there, cap the response body. The decoder reads whatever
the endpoint sends.
ErrTooLarge's comment says the cap stops "arunaway capture" filling "the disk that mavend's database lives on". Nothing
limits blob count. Content-addressed storage dedupes identical bytes, and one
flipped pixel defeats that. 64 MiB per call times
unlimited calls inside a 7-day window fills the disk. A total-bytes budget,
checked in
Putagainst a cheap running total, is what the comment describes.MediaConfigandVisionConfigget novalidate()entry, unlikemcp. Adirthat cannot be created is caught atopenMediaStoreand logged, so thecapability silently stays off. A typo in
endpointis the same. Both are thekind of thing that should fail at startup.
describetakes the
IDbranch and drops the bytes without a word. The doc onDescribeImageReqstates the rule. The code should too.extFormapsimage/webpto.webp, butSniffImagerefuses webp beforeanything reaches
Put, so that arm is unreachable for images. Harmless, butit reads as though webp works.
runPruneis started with a baregoand is not in the daemon'swg, unlikethe other loops in
run. Shutdown does not wait for a prune in flight.Prune,Putfailingbetween blob and sidecar, and
SaveNotewhen the embedder is nil.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