From 880715fad456d74046b80a7c463bbc25c1b9a006 Mon Sep 17 00:00:00 2001 From: kami Date: Mon, 6 Jul 2026 04:18:50 +0400 Subject: [PATCH] maven: long-term memory vector-store interface (task 7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New internal/memory/ package: Store interface, InMemoryStore (cosine sim) - Tests: insert→search roundtrip, topK truncation, empty store, cosine edges - Wire into voice: memStore on reactiveHandler, insert note embedding after WriteNote - Memory Insert is best-effort, log-and-continue on error Co-Authored-By: opencode --- cmd/mavend/voice.go | 21 +++++++- internal/memory/store.go | 95 +++++++++++++++++++++++++++++++++++ internal/memory/store_test.go | 80 +++++++++++++++++++++++++++++ 3 files changed, 194 insertions(+), 2 deletions(-) create mode 100644 internal/memory/store.go create mode 100644 internal/memory/store_test.go diff --git a/cmd/mavend/voice.go b/cmd/mavend/voice.go index 10f9183..c00fc7a 100644 --- a/cmd/mavend/voice.go +++ b/cmd/mavend/voice.go @@ -51,10 +51,12 @@ import ( "os" "path/filepath" "strings" + "strconv" "sync" "time" "github.com/kami/maven/internal/audio" + "github.com/kami/maven/internal/memory" "github.com/kami/maven/internal/config" "github.com/kami/maven/internal/delivery" "github.com/kami/maven/internal/delivery/voicesink" @@ -200,7 +202,10 @@ func wireVoice(cfg *config.Config, coreAPI ipc.CoreAPI, phr phraser.Phraser) (*v // ----- voice sink (proactive nudges: dispatcher → voicesink → tts → push to client) ----- w.voiceSink = voicesink.New(synthesizer, sessions) - // ----- the handler (the reactive path; closes over stt / tts / router / coreAPI) ----- + // ----- memory (long-term vector storage, in-memory for now) ----- + memStore := memory.NewInMemoryStore() + + // ----- the handler (the reactive path; closes over stt / tts / router / coreAPI / memory) ----- h := &reactiveHandler{ stt: transcriber, tts: synthesizer, @@ -213,6 +218,7 @@ func wireVoice(cfg *config.Config, coreAPI ipc.CoreAPI, phr phraser.Phraser) (*v now: time.Now, weatherProvider: weatherProvider, weatherLocation: weatherLocation, + memStore: memStore, } // ----- the server (TCP listener) ----- @@ -244,6 +250,8 @@ type reactiveHandler struct { weatherProvider weather.Provider weatherLocation string // default location for weather queries + memStore memory.Store + // pending destructive-act confirmation. A destructive act replies with a // "выполнить X? да/нет" prompt and parks here; the NEXT utterance is read as // the y/n answer. ponytail: single slot, single-user box — a second act @@ -422,10 +430,19 @@ func (h *reactiveHandler) applyAction(ctx context.Context, dec router.Decision) log.Printf("voice: embed note: %v", err) return "не получилось сохранить заметку." } - if _, err := h.api.WriteNote(ctx, h.now(), dec.Utterance, vec, "tap:voice"); err != nil { + noteID, err := h.api.WriteNote(ctx, h.now(), dec.Utterance, vec, "tap:voice") + if err != nil { log.Printf("voice: write note: %v", err) return "не получилось сохранить заметку." } + // Insert into long-term memory (best-effort, must not fail the note write) + if h.memStore != nil { + if err := h.memStore.Insert(ctx, strconv.FormatInt(noteID, 10), vec, map[string]string{ + "source": "voice", + }); err != nil { + log.Printf("voice: memory insert: %v", err) + } + } return "" // replier phrases the "saved" reply case router.IntentQuery: diff --git a/internal/memory/store.go b/internal/memory/store.go new file mode 100644 index 0000000..33dfa3f --- /dev/null +++ b/internal/memory/store.go @@ -0,0 +1,95 @@ +package memory + +import ( + "context" + "sort" + "sync" +) + +// Result is a single search hit. +type Result struct { + ID string + Score float64 + Meta map[string]string +} + +// Store is a vector memory interface. +type Store interface { + Insert(ctx context.Context, id string, vec []float32, meta map[string]string) error + Search(ctx context.Context, vec []float32, topK int) ([]Result, error) +} + +// item is a single stored vector with metadata. +type item struct { + id string + vec []float32 + meta map[string]string +} + +// InMemoryStore implements Store with cosine similarity search. +type InMemoryStore struct { + mu sync.RWMutex + items []item +} + +func NewInMemoryStore() *InMemoryStore { + return &InMemoryStore{} +} + +func (s *InMemoryStore) Insert(_ context.Context, id string, vec []float32, meta map[string]string) error { + s.mu.Lock() + s.items = append(s.items, item{id: id, vec: vec, meta: meta}) + s.mu.Unlock() + return nil +} + +func (s *InMemoryStore) Search(_ context.Context, vec []float32, topK int) ([]Result, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + if topK <= 0 { + topK = 10 + } + + type scored struct { + id string + score float64 + meta map[string]string + } + + scores := make([]scored, 0, len(s.items)) + for _, it := range s.items { + score := cosine(vec, it.vec) + scores = append(scores, scored{id: it.id, score: score, meta: it.meta}) + } + + sort.Slice(scores, func(i, j int) bool { + return scores[i].score > scores[j].score // descending + }) + + if topK > len(scores) { + topK = len(scores) + } + + out := make([]Result, topK) + for i := 0; i < topK; i++ { + out[i] = Result{ + ID: scores[i].id, + Score: scores[i].score, + Meta: scores[i].meta, + } + } + return out, nil +} + +// cosine similarity (dot product, assumes L2-normalized vectors). +func cosine(a, b []float32) float64 { + if len(a) != len(b) || len(a) == 0 { + return 0 + } + var dot float64 + for i := range a { + dot += float64(a[i]) * float64(b[i]) + } + return dot +} diff --git a/internal/memory/store_test.go b/internal/memory/store_test.go new file mode 100644 index 0000000..f066cc7 --- /dev/null +++ b/internal/memory/store_test.go @@ -0,0 +1,80 @@ +package memory + +import ( + "context" + "math" + "testing" +) + +func TestInsertAndSearch(t *testing.T) { + s := NewInMemoryStore() + ctx := context.Background() + + // Insert a few vectors + s.Insert(ctx, "doc1", []float32{1, 0, 0}, nil) + s.Insert(ctx, "doc2", []float32{0, 1, 0}, nil) + s.Insert(ctx, "doc3", []float32{0, 0, 1}, map[string]string{"source": "note"}) + + // Search for something close to doc1 + results, err := s.Search(ctx, []float32{0.9, 0.1, 0}, 5) + if err != nil { + t.Fatalf("Search: %v", err) + } + if len(results) != 3 { + t.Fatalf("expected 3 results, got %d", len(results)) + } + if results[0].ID != "doc1" { + t.Errorf("nearest should be doc1, got %s", results[0].ID) + } + if math.Abs(results[0].Score-0.9) > 0.01 { + t.Errorf("doc1 score should be near 0.9, got %f", results[0].Score) + } + + // Check metadata preserved + if results[2].Meta["source"] != "note" { + t.Errorf("doc3 meta.source = %q, want note", results[2].Meta["source"]) + } +} + +func TestTopKTruncation(t *testing.T) { + s := NewInMemoryStore() + ctx := context.Background() + + for i := 0; i < 10; i++ { + s.Insert(ctx, "", []float32{float32(i) / 10, 0, 0}, nil) + } + + results, err := s.Search(ctx, []float32{1, 0, 0}, 3) + if err != nil { + t.Fatalf("Search: %v", err) + } + if len(results) != 3 { + t.Fatalf("expected 3 results with topK=3, got %d", len(results)) + } +} + +func TestEmptyStore(t *testing.T) { + s := NewInMemoryStore() + results, err := s.Search(context.Background(), []float32{1, 0, 0}, 5) + if err != nil { + t.Fatalf("Search on empty store: %v", err) + } + if len(results) != 0 { + t.Fatalf("expected 0 results, got %d", len(results)) + } +} + +func TestCosineEdgeCases(t *testing.T) { + if c := cosine(nil, []float32{1}); c != 0 { + t.Errorf("nil first: expected 0, got %f", c) + } + if c := cosine([]float32{1}, nil); c != 0 { + t.Errorf("nil second: expected 0, got %f", c) + } + if c := cosine([]float32{}, []float32{}); c != 0 { + t.Errorf("empty: expected 0, got %f", c) + } + if c := cosine([]float32{1, 2}, []float32{1, 2}); math.Abs(c-5) > 0.001 { + t.Errorf("dot(1,2;1,2) = %f, want 5", c) + } +}