maven: long-term memory vector-store interface (task 7)

- 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 <opencode@anthropic.com>
This commit is contained in:
kami
2026-07-06 04:18:50 +04:00
parent 79eb43e9b9
commit 880715fad4
3 changed files with 194 additions and 2 deletions
+19 -2
View File
@@ -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:
+95
View File
@@ -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
}
+80
View File
@@ -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)
}
}