Files
Maven/internal/media/media.go
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

120 lines
5.4 KiB
Go

// Package media is the intake for everything Maven sees or hears that is not
// text: a photo he sends her, a meeting she was asked to record, a voice sample
// used to enrol a speaker. All three senses (vision, hearing, speaker
// recognition) share one problem — a blob arrives, it has to be stored, and
// something has to describe it — so the storing half lives here once instead of
// three times.
//
// # What this package is
//
// A content-addressed blob store on the local filesystem. Put returns a Blob
// keyed by the sha256 of its bytes, so the same photo sent twice is one file.
// Each blob gets a sidecar `.json` with its kind, mime, size, source and
// creation time; the sidecar is the whole index, because at personal scale a
// directory walk is cheaper than another sqlite table and the store has to be
// readable with `ls` when something goes wrong.
//
// Blobs are NOT in the sqlite database. The database is small, encrypted, and
// read on every tick; a 40 MB meeting recording has no business in it. What
// goes in the database is the *text* a blob produced — a transcript, a
// description — written as an ordinary note, which is the durable artefact and
// the only part worth recalling later.
//
// # Invariants (these are the point of the package, not decoration)
//
// - Nothing is captured that was not asked for. This package never records;
// it stores what a caller hands it, and every caller is an explicit act
// with a start and a stop. There is no ambient path in, and none may be
// added: see the refusal recorded in docs/plans/08-hearing.md.
// - A blob never leaves the box. No provider in this repo may upload one, and
// the vision provider refuses a non-private endpoint for exactly that
// reason (internal/vision).
// - A blob is never search input and never embedded. His photos and the audio
// of his meetings are not corpus. Only text derived from them, once he can
// see it as a note, participates in recall.
// - Storage is bounded. Retention is a config knob with a default, Prune
// enforces it, and an unpruned store is a bug: audio of people accumulating
// forever on disk is the failure mode this capability has to avoid.
//
// # Layout
//
// <dir>/<kind>/<aa>/<sha256>.<ext> the bytes
// <dir>/<kind>/<aa>/<sha256>.json the sidecar metadata
//
// `aa` is the first two hex chars of the digest — one fan-out level, enough to
// keep a directory listing usable after a few thousand blobs.
package media
import (
"errors"
"fmt"
"time"
)
// Kind — what a blob is. Two values today; the kind is a directory name and a
// retention bucket, so adding a third is additive.
type Kind string
const (
// KindImage — a still image (png / jpeg / gif / webp bytes as received).
KindImage Kind = "image"
// KindAudio — raw PCM in the canonical internal/audio format, or a WAV
// container. Meeting captures and enrolment samples both land here.
KindAudio Kind = "audio"
)
// Valid reports whether k is a kind this package will store. An unknown kind is
// refused at Put rather than creating a stray directory.
func (k Kind) Valid() bool { return k == KindImage || k == KindAudio }
// Errors callers distinguish. ErrNotFound is the only one a caller usually
// handles; the rest mean the call was wrong.
var (
// ErrNotFound — no blob with that id in this store.
ErrNotFound = errors.New("media: not found")
// ErrEmpty — Put was handed zero bytes. Storing an empty capture would
// leave a sidecar claiming a recording exists when it does not.
ErrEmpty = errors.New("media: empty payload")
// ErrTooLarge — the payload is over the store's cap. The cap exists so a
// runaway capture cannot fill the disk that mavend's database lives on.
ErrTooLarge = errors.New("media: payload too large")
// ErrBadKind — unknown Kind.
ErrBadKind = errors.New("media: unknown kind")
// ErrBadID — the id is not a 64-char lowercase hex digest, so it cannot
// have come from this store and must not be turned into a path.
ErrBadID = errors.New("media: malformed id")
)
// Blob — one stored item. ID is the sha256 of the bytes in lowercase hex, which
// makes it both the primary key and the dedupe mechanism. Path is absolute and
// local; it is a debugging affordance and the argument a subprocess (whisper,
// llama-server) is pointed at, never something handed to a network client.
type Blob struct {
ID string `json:"id"`
Kind Kind `json:"kind"`
MIME string `json:"mime"`
Size int64 `json:"size"`
Source string `json:"source"` // provenance: "telegram", "web:upload", "capture:meeting", "enroll"
Created time.Time `json:"created"` // UTC
Path string `json:"-"` // filled by the store; not part of the sidecar
}
// Age is how long ago the blob was stored, measured against now. Prune uses it;
// it is exported because the /media surface will want to show it.
func (b Blob) Age(now time.Time) time.Duration { return now.Sub(b.Created) }
// String is a one-line summary for logs. Deliberately does not include Path:
// a log line is not the place to spell out where his meeting audio lives.
func (b Blob) String() string {
return fmt.Sprintf("%s %s %dB from %s", b.Kind, shortID(b.ID), b.Size, b.Source)
}
// shortID trims a digest to something readable in a log line. Twelve hex chars
// is unambiguous at personal scale and short enough to fit next to the rest.
func shortID(id string) string {
if len(id) <= 12 {
return id
}
return id[:12]
}