Files
Maven/internal/store/memory.go
kami 59a4e06615 maven: scheduled routines + persistent long-term memory
Two additive proactive/recall features.

Routines (internal/routine): a third proactive class beside reminders
(user-stated) and care rules (world-state) — operator-declared clockwork.
config.routines[] (cron + literal RU body + severity) fire through the
normal dispatcher on schedule. Bodies are literal, not LLM-phrased (can't
hallucinate); rule name routine:<name> keeps them out of the care
autotuner; a cold-start guard seeds on first sight so a restart never
replays a missed schedule. Pure routine.Due + config validation, unit-
tested; the tick driver holds the last-fired map and calls fireRoutines.

Persistent memory (internal/store/memory.go): store.MemoryStore backs the
memory.Store interface with the SAME encrypted sqlite db — survives
restarts and recall text inherits at-rest encryption (no plaintext
sidecar). float32-blob vectors, brute-force cosine (ANN is a later swap
behind the interface), upsert-by-id. The daemon wires st.VectorMemory()
into wireVoice; the in-memory impl stays the test/no-store floor. Closes
the "in-memory only, lost on restart" gap (PROGRESS #8).

Gate green: gofmt/vet clean, -race across routine/config/store/mavend.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U2PNdwDj2Gt8YW294J7oSc
2026-07-06 12:37:28 +04:00

132 lines
4.3 KiB
Go

package store
import (
"context"
"database/sql"
"encoding/binary"
"encoding/json"
"fmt"
"math"
"sort"
"time"
"github.com/kami/maven/internal/memory"
)
// MemoryStore is the persistent backend for internal/memory's vector Store,
// sharing the main encrypted sqlite database so recall text (note/fact bodies
// carried in the meta blob) inherits at-rest encryption — a plaintext sidecar
// file would undercut store.OpenEncrypted. It survives daemon restarts, which
// the InMemoryStore does not: that was the last gap keeping long-term memory
// from being real.
//
// Search is brute-force cosine over every row loaded into memory — the same
// algorithm as InMemoryStore, just sourced from disk. At the single-user note+
// fact scale (thousands of rows, not millions) a full scan per query is well
// under a millisecond; an ANN index is the swap for later, behind this same
// interface. Vectors are assumed L2-normalized by the embedder, so cosine is a
// dot product.
type MemoryStore struct {
db *sql.DB
}
// VectorMemory returns a persistent memory.Store backed by this store's db.
// The returned store shares the db handle (single writer — the daemon), so it
// participates in the same encrypted tmpfs working copy and is sealed on Close.
func (s *Store) VectorMemory() *MemoryStore {
return &MemoryStore{db: s.db}
}
// compile-time check: MemoryStore satisfies the memory.Store interface.
var _ memory.Store = (*MemoryStore)(nil)
// Insert upserts a vector by id: a repeated id replaces the prior row rather
// than accumulating duplicates (the note/fact ids are stable and unique, so a
// re-index is an update, not a second copy — an improvement on InMemoryStore's
// append-always). meta is stored as a JSON object.
func (m *MemoryStore) Insert(ctx context.Context, id string, vec []float32, meta map[string]string) error {
metaJSON, err := json.Marshal(meta)
if err != nil {
return fmt.Errorf("memory: marshal meta: %w", err)
}
_, err = m.db.ExecContext(ctx,
`INSERT INTO memory_vectors (id, vec, meta, created_ts) VALUES (?,?,?,?)
ON CONFLICT(id) DO UPDATE SET vec = excluded.vec, meta = excluded.meta, created_ts = excluded.created_ts`,
id, encodeVec(vec), string(metaJSON), time.Now().UnixMilli())
if err != nil {
return fmt.Errorf("memory: insert %q: %w", id, err)
}
return nil
}
// Search returns the topK nearest rows by cosine similarity. A full scan; see
// the type doc for why that's fine at this scale.
func (m *MemoryStore) Search(ctx context.Context, vec []float32, topK int) ([]memory.Result, error) {
if topK <= 0 {
topK = 10
}
rows, err := m.db.QueryContext(ctx, `SELECT id, vec, meta FROM memory_vectors`)
if err != nil {
return nil, fmt.Errorf("memory: scan: %w", err)
}
defer rows.Close()
var out []memory.Result
for rows.Next() {
var id, metaJSON string
var blob []byte
if err := rows.Scan(&id, &blob, &metaJSON); err != nil {
return nil, fmt.Errorf("memory: row: %w", err)
}
meta := map[string]string{}
if err := json.Unmarshal([]byte(metaJSON), &meta); err != nil {
return nil, fmt.Errorf("memory: unmarshal meta for %q: %w", id, err)
}
out = append(out, memory.Result{ID: id, Score: dot(vec, decodeVec(blob)), Meta: meta})
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("memory: rows: %w", err)
}
sort.Slice(out, func(i, j int) bool { return out[i].Score > out[j].Score })
if topK < len(out) {
out = out[:topK]
}
return out, nil
}
// encodeVec serializes a float32 slice as little-endian IEEE-754 bytes (4 bytes
// per element) for the BLOB column.
func encodeVec(v []float32) []byte {
b := make([]byte, 4*len(v))
for i, f := range v {
binary.LittleEndian.PutUint32(b[4*i:], math.Float32bits(f))
}
return b
}
// decodeVec reverses encodeVec. A blob whose length isn't a multiple of 4 is
// truncated to the whole-element prefix (defensive — a well-formed row can't
// produce that).
func decodeVec(b []byte) []float32 {
n := len(b) / 4
v := make([]float32, n)
for i := 0; i < n; i++ {
v[i] = math.Float32frombits(binary.LittleEndian.Uint32(b[4*i:]))
}
return v
}
// dot is the cosine similarity for L2-normalized vectors (mismatched lengths ⇒
// 0, matching internal/memory's cosine).
func dot(a, b []float32) float64 {
if len(a) != len(b) || len(a) == 0 {
return 0
}
var sum float64
for i := range a {
sum += float64(a[i]) * float64(b[i])
}
return sum
}