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
This commit is contained in:
@@ -0,0 +1,131 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func newMemTestStore(t *testing.T) *Store {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), "mem_test.db")
|
||||
st, err := Open(context.Background(), path)
|
||||
if err != nil {
|
||||
t.Fatalf("Open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = st.Close() })
|
||||
return st
|
||||
}
|
||||
|
||||
func TestMemoryStoreInsertSearch(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
m := newMemTestStore(t).VectorMemory()
|
||||
|
||||
// three orthonormal-ish vectors; a query aligned with the second must rank it top.
|
||||
if err := m.Insert(ctx, "a", []float32{1, 0, 0}, map[string]string{"text": "вода"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := m.Insert(ctx, "b", []float32{0, 1, 0}, map[string]string{"text": "сон", "type": "fact"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := m.Insert(ctx, "c", []float32{0, 0, 1}, map[string]string{"text": "еда"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err := m.Search(ctx, []float32{0, 1, 0}, 2)
|
||||
if err != nil {
|
||||
t.Fatalf("Search: %v", err)
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("topK=2 returned %d results", len(got))
|
||||
}
|
||||
if got[0].ID != "b" {
|
||||
t.Errorf("top hit = %q, want b", got[0].ID)
|
||||
}
|
||||
if got[0].Meta["text"] != "сон" || got[0].Meta["type"] != "fact" {
|
||||
t.Errorf("meta not round-tripped: %v", got[0].Meta)
|
||||
}
|
||||
if got[0].Score < 0.99 {
|
||||
t.Errorf("aligned vector score = %v, want ~1.0", got[0].Score)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemoryStoreUpsertReplaces(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
m := newMemTestStore(t).VectorMemory()
|
||||
|
||||
if err := m.Insert(ctx, "x", []float32{1, 0}, map[string]string{"text": "старое"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := m.Insert(ctx, "x", []float32{0, 1}, map[string]string{"text": "новое"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := m.Search(ctx, []float32{0, 1}, 10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 1 {
|
||||
t.Fatalf("re-inserting the same id produced %d rows, want 1 (upsert)", len(got))
|
||||
}
|
||||
if got[0].Meta["text"] != "новое" {
|
||||
t.Errorf("upsert kept the old value: %q", got[0].Meta["text"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMemoryStorePersistsAcrossReopen(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
path := filepath.Join(t.TempDir(), "persist.db")
|
||||
|
||||
st, err := Open(ctx, path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.VectorMemory().Insert(ctx, "k", []float32{1, 0, 0}, map[string]string{"text": "запомни"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// reopen the same file — the in-memory floor would have lost this.
|
||||
st2, err := Open(ctx, path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = st2.Close() })
|
||||
got, err := st2.VectorMemory().Search(ctx, []float32{1, 0, 0}, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 1 || got[0].Meta["text"] != "запомни" {
|
||||
t.Fatalf("memory did not survive reopen: %v", got)
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,12 @@ var migrations = []string{
|
||||
`ALTER TABLE tools ADD COLUMN scope TEXT NOT NULL DEFAULT 'homelab';`, // #1
|
||||
`ALTER TABLE reminders ADD COLUMN cron TEXT;
|
||||
ALTER TABLE reminders ADD COLUMN next_fire_ts INTEGER;`, // #2
|
||||
`CREATE TABLE memory_vectors (
|
||||
id TEXT PRIMARY KEY,
|
||||
vec BLOB NOT NULL,
|
||||
meta TEXT NOT NULL DEFAULT '{}',
|
||||
created_ts INTEGER NOT NULL
|
||||
);`, // #3 — long-term vector memory (persistent backend for internal/memory)
|
||||
}
|
||||
|
||||
// migrate applies every migration with a number greater than the DB's current
|
||||
|
||||
Reference in New Issue
Block a user