Skip to content

Commit 686fb6f

Browse files
jkyberneeesclaude
andauthored
feat(memory): persisted go-vector episode index + featurization quality (#12)
* feat(memory): persisted go-vector episode index + featurization quality Fixes the two remaining memory weaknesses using only the existing go-vector library — no new embedding dependency. **#4 — per-turn LLM call eliminated.** FormatEpisodeContext previously called episodes.Search → NewLLMRanker → one llm.SimpleCall per turn. The RP fallback was no better at scale: NewRPRanker re-instantiated RandomProjections and re-fit + re-embedded every episode on every Search call (no caching). New episodeVectorIndex (mirroring session/vector_index.go) persists a go-vector Store + RandomProjections embedder to gob files. FormatEpisodeContext now calls episodes.recallByVector → sharedEpisodeIndex.search → embed query + N cached cosines. Zero LLM calls on the per-turn path. Design: dirty-flag + full rebuild. RP must be Fit on the full corpus to produce a valid vocabulary — incremental embedding after a stale Fit yields degenerate vectors. Episodes are written at session-end (infrequent); rebuild is triggered on the next search after any write, then cached for all subsequent turns until the next write. One O(n) rebuild per session-end, then µs cosine per turn. Process-wide singleton per memory directory (sharedEpisodeIndex, mirroring factsDirLock) prevents concurrent serve.go per-connection managers from racing on the gob files. SearchEpisodes (explicit memory tool) now fetches candidates from the vector index and LLM-reranks only those candidates (bounded), fixing the O(n)·LLM cost in the explicit search path too. llm_search now gates explicit search reranking only — per-turn recall always uses RP. **#3 — featurization quality.** New featurize.go: normalizeForEmbedding (lowercase + alphanumeric tokens, strips punctuation so "Postgres," == "postgres") + featurizeForEmbedding (normalise + bigrams "w1_w2" for light local word order). Applied at the go-vector boundary in both the episode index (full ~1KB on-disk summaries, up from 120-char truncated; 256 dims, up from 64) and MergeDetector.Fit/ Classify/AppendEntry/ReplaceEntry. Raw strings are preserved in m.corpus; only the RP boundary uses featurized text. Verified: FormatEpisodeContext fires zero LLM calls; postgres vs mysql episodes rank distinctly; postgres vs "database is postgres" ranks similar; persistence round-trips; dirty rebuild picks up new episodes; provenance filter holds; concurrent safety under -race; all existing tests green. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * fix(memory): close verification findings on episode vector index (D-04/D-05/D-06) Remediates the AI Verification Protocol findings on PR #12. D-05 (OOV zero-score bypass): when a query has no vocabulary overlap with the episode corpus, go-vector Embed returns a zero vector and Store.Search returns k results all with cosine similarity=0. recallByVector was returning those as non-empty candidates, so SearchEpisodes skipped the LLM fallback with noise. Fix: filter zero-score results in recallByVector before returning; an all-OOV query now returns nil, correctly triggering the fallback path in SearchEpisodes. D-06 (SearchEpisodes at 52.9% coverage): four branches untested. Added: llm_search=false (no LLM), nil LLM client, limit truncation, rankFn error fallback, and OOV query triggering the nil-then-fallback path. Coverage now 76.5%. D-04 (multi-process caveat undocumented): the in-process singleton serializes concurrent MemoryManagers within one process, but two separate odek processes sharing ~/.odek/memory are not serialized. Added explicit documentation to the singleton var block explaining the limitation and its bounded impact. D-01/D-02/D-03/D-07/D-08/D-09 confirmed held (no fix needed): double-checked locking is correct; per-TempDir tests prevent singleton bleed; corrupted emb gob falls back to rebuild; featurization is symmetric across all Fit/Embed paths. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
1 parent 0f9f586 commit 686fb6f

8 files changed

Lines changed: 880 additions & 21 deletions

File tree

docs/CONFIG.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -217,11 +217,11 @@ The `memory` section controls the persistent memory system (see [docs/MEMORY.md]
217217
| `merge_on_write` | true | Use go-vector RP similarity to auto-merge related entries |
218218
| `extract_on_end` | true | At session end (≥3 turns), extract a narrative episode summary via LLM for later recall |
219219
| `extract_facts` | **false** | **Opt-in.** At session end (≥3 turns), auto-extract a few **durable** facts (stable user preferences, project invariants) into `user.md`/`env.md`. Off by default — see the security note below. Independent of `extract_on_end`; to disable *all* end-of-session LLM extraction set `llm_extract: false`. |
220-
| `llm_search` | true | Use LLM to rank episode search results by relevance |
220+
| `llm_search` | true | Use LLM to rerank candidates for **explicit** `memory search` calls (the `memory` tool). Per-turn recall (`FormatEpisodeContext`) always uses the cached go-vector index — no LLM call on the hot path regardless of this setting. |
221221
| `llm_extract` | true | Use LLM for end-of-session fact extraction |
222222
| `llm_consolidate` | true | Use LLM to merge related fact entries |
223-
| `merge_threshold` | 0.7 | go-vector cosine threshold for auto-merge (0.0–1.0) |
224-
| `add_threshold` | 0.3 | go-vector cosine threshold for auto-add (0.0–1.0) |
223+
| `merge_threshold` | 0.7 | Cosine similarity above which two fact entries are **auto-merged** without an LLM call (0.0–1.0). Raise it to merge less aggressively; lower it to merge more. |
224+
| `add_threshold` | 0.3 | Cosine similarity below which a new fact entry is **auto-added** without an LLM call (0.0–1.0). Between `add_threshold` and `merge_threshold` the LLM decides. Keep `add_threshold` < `merge_threshold`. |
225225
| `auto_approve_episodes` | false | **Security trade-off.** When true, untrusted episodes (sessions that touched web/MCP/out-of-workspace content) are auto-approved at session end so they are recalled without a manual `odek memory promote`. Leaving it `false` keeps the human review gate (recommended). |
226226

227227
### ⚠️ `extract_facts` — automatic fact learning (opt-in, off by default)

internal/memory/episode_index.go

Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
1+
package memory
2+
3+
import (
4+
"encoding/json"
5+
"os"
6+
"path/filepath"
7+
"strings"
8+
"sync"
9+
"time"
10+
11+
"github.com/BackendStack21/go-vector/pkg/vector"
12+
)
13+
14+
const (
15+
// episodeVectorDim matches the session and fact-merge embedders (256).
16+
episodeVectorDim = 256
17+
18+
episodeVectorFile = "episodes_vectors.gob"
19+
episodeEmbedFile = "episodes_embedder.gob"
20+
)
21+
22+
// scoredEpisode pairs a session ID with its cosine similarity to a query.
23+
type scoredEpisode struct {
24+
ID string
25+
Score float32
26+
}
27+
28+
// episodeVectorIndex is a persisted go-vector RandomProjections embedder +
29+
// brute-force k-NN store for per-turn episode recall.
30+
//
31+
// Rationale for dirty-flag + full-rebuild design: go-vector's RandomProjections
32+
// must be Fit on the FULL corpus to build a valid vocabulary — embedding
33+
// incrementally with a stale Fit produces degenerate vectors for new terms.
34+
// Episodes are written at session-end (infrequent); recall fires every turn
35+
// (frequent). The trade-off is one O(n) rebuild after each new episode, then
36+
// fast cached cosine on every subsequent turn until the next write.
37+
//
38+
// Thread-safety: all exported methods hold vi.mu as appropriate.
39+
// Per-directory singleton: see sharedEpisodeIndex.
40+
type episodeVectorIndex struct {
41+
mu sync.RWMutex
42+
store *vector.Store
43+
emb *vector.RandomProjections
44+
dir string
45+
ready bool
46+
dirty bool
47+
}
48+
49+
// ── Per-directory singleton ───────────────────────────────────────────────────
50+
51+
// episodeIndexes holds one *episodeVectorIndex per absolute memory directory,
52+
// shared across all MemoryManager / EpisodeStore instances in the process.
53+
// odek serve builds one manager per WebSocket connection — all over the same
54+
// ~/.odek/memory — so a per-instance index would race on the .gob files.
55+
//
56+
// Multi-process note: two separate odek processes sharing the same memory
57+
// directory are NOT serialized by this in-process singleton. Concurrent saves
58+
// from distinct processes can interleave on the .tmp files. The practical
59+
// impact is limited — the worst case is one process loading a recently-rebuilt
60+
// gob pair and getting slightly stale recall — but operators running multiple
61+
// odek processes against a shared ~/.odek/memory should be aware of this.
62+
// Each process still produces internally-consistent gob pairs (store+emb are
63+
// rebuilt atomically within one process); the risk is only cross-process.
64+
var (
65+
epIdxMu sync.Mutex
66+
epIdxes = map[string]*episodeVectorIndex{}
67+
)
68+
69+
// sharedEpisodeIndex returns the process-wide index for dir, creating it on
70+
// first call. The index is lazily initialised on first search.
71+
func sharedEpisodeIndex(dir string) *episodeVectorIndex {
72+
abs, err := filepath.Abs(dir)
73+
if err != nil {
74+
abs = dir
75+
}
76+
epIdxMu.Lock()
77+
defer epIdxMu.Unlock()
78+
if vi, ok := epIdxes[abs]; ok {
79+
return vi
80+
}
81+
vi := &episodeVectorIndex{dir: abs}
82+
epIdxes[abs] = vi
83+
return vi
84+
}
85+
86+
// ── Public methods ────────────────────────────────────────────────────────────
87+
88+
// markDirty signals that the on-disk episodes changed. The next search will
89+
// rebuild the index before serving results. No disk I/O on this path.
90+
func (vi *episodeVectorIndex) markDirty() {
91+
vi.mu.Lock()
92+
vi.dirty = true
93+
vi.mu.Unlock()
94+
}
95+
96+
// search rebuilds if needed, embeds the query, and returns up to k nearest
97+
// episodes by cosine similarity. Returns nil if the index is empty or an error
98+
// occurs — callers treat this as "no context available" and carry on.
99+
func (vi *episodeVectorIndex) search(query string, k int) []scoredEpisode {
100+
vi.ensureFresh()
101+
vi.mu.RLock()
102+
defer vi.mu.RUnlock()
103+
if !vi.ready || vi.store == nil || vi.store.Len() == 0 || vi.emb == nil {
104+
return nil
105+
}
106+
if k <= 0 {
107+
k = 5
108+
}
109+
vec, err := vi.emb.Embed(featurizeForEmbedding(query))
110+
if err != nil {
111+
return nil
112+
}
113+
res := vi.store.Search(vec, k)
114+
out := make([]scoredEpisode, 0, len(res))
115+
for _, r := range res {
116+
out = append(out, scoredEpisode{ID: r.ID, Score: 1 - r.Distance})
117+
}
118+
return out
119+
}
120+
121+
// ── Internal helpers ──────────────────────────────────────────────────────────
122+
123+
// ensureFresh loads or rebuilds the index as needed. Must NOT be called while
124+
// holding vi.mu (it acquires it internally).
125+
func (vi *episodeVectorIndex) ensureFresh() {
126+
vi.mu.RLock()
127+
if vi.ready && !vi.dirty {
128+
vi.mu.RUnlock()
129+
return
130+
}
131+
vi.mu.RUnlock()
132+
133+
vi.mu.Lock()
134+
defer vi.mu.Unlock()
135+
if vi.ready && !vi.dirty {
136+
return // double-checked
137+
}
138+
139+
// Cold start without a pending write: try the persisted gobs first.
140+
if !vi.ready && !vi.dirty {
141+
if vi.tryLoadLocked() {
142+
return
143+
}
144+
}
145+
// Either cold-start without gobs, or dirty after a write — full rebuild.
146+
vi.rebuildLocked()
147+
}
148+
149+
// tryLoadLocked attempts to load persisted state. Returns true on success.
150+
// Caller must hold vi.mu (write lock).
151+
func (vi *episodeVectorIndex) tryLoadLocked() bool {
152+
store := vector.NewStore(vector.CosineDistance)
153+
if err := store.Load(filepath.Join(vi.dir, episodeVectorFile)); err != nil {
154+
return false
155+
}
156+
emb, err := vector.LoadEmbedder(filepath.Join(vi.dir, episodeEmbedFile))
157+
if err != nil {
158+
return false
159+
}
160+
vi.store = store
161+
vi.emb = emb
162+
vi.ready = true
163+
return true
164+
}
165+
166+
// rebuildLocked reads all episode summaries from disk, fits the RP embedder on
167+
// the full corpus, and persists the result. Caller must hold vi.mu (write lock).
168+
func (vi *episodeVectorIndex) rebuildLocked() {
169+
texts := vi.readAllSummaries()
170+
171+
corpus := make([]string, len(texts))
172+
for i, t := range texts {
173+
corpus[i] = featurizeForEmbedding(t.text)
174+
}
175+
176+
emb := vector.NewRandomProjections(episodeVectorDim)
177+
emb.Fit(corpus)
178+
179+
store := vector.NewStore(vector.CosineDistance)
180+
for i, t := range texts {
181+
vec, err := emb.Embed(corpus[i])
182+
if err != nil {
183+
continue
184+
}
185+
store.Add(t.id, vec)
186+
}
187+
188+
vi.store = store
189+
vi.emb = emb
190+
vi.ready = true
191+
vi.dirty = false
192+
vi.saveLocked()
193+
}
194+
195+
type idText struct {
196+
id string
197+
text string
198+
}
199+
200+
// readAllSummaries reads the JSON episode index and then the full on-disk
201+
// summary for each entry. Unreadable entries are silently skipped.
202+
func (vi *episodeVectorIndex) readAllSummaries() []idText {
203+
// Parse index.json directly — avoids importing EpisodeStore / circular deps.
204+
type meta struct {
205+
SessionID string `json:"session_id"`
206+
CreatedAt time.Time `json:"created_at"`
207+
}
208+
data, err := os.ReadFile(filepath.Join(vi.dir, episodeIndexFile))
209+
if err != nil {
210+
return nil
211+
}
212+
var index []meta
213+
if err := json.Unmarshal(data, &index); err != nil {
214+
return nil
215+
}
216+
out := make([]idText, 0, len(index))
217+
for _, m := range index {
218+
path := filepath.Join(vi.dir, m.SessionID+".md")
219+
b, err := os.ReadFile(path)
220+
if err != nil {
221+
continue
222+
}
223+
text := strings.TrimSpace(string(b))
224+
if text == "" {
225+
continue
226+
}
227+
out = append(out, idText{id: m.SessionID, text: text})
228+
}
229+
return out
230+
}
231+
232+
// saveLocked atomically persists the store and embedder. Caller must hold
233+
// vi.mu (write lock). Fixed temp names are safe because the index is a
234+
// per-dir singleton, so all saves funnel through this mutex-guarded method.
235+
func (vi *episodeVectorIndex) saveLocked() {
236+
if vi.store == nil || vi.emb == nil || vi.dir == "" {
237+
return
238+
}
239+
storePath := filepath.Join(vi.dir, episodeVectorFile)
240+
if tmp := storePath + ".tmp"; vi.store.Save(tmp) == nil {
241+
if err := os.Rename(tmp, storePath); err != nil {
242+
os.Remove(tmp)
243+
}
244+
}
245+
embPath := filepath.Join(vi.dir, episodeEmbedFile)
246+
if tmp := embPath + ".tmp"; vi.emb.SaveEmbedder(tmp) == nil {
247+
if err := os.Rename(tmp, embPath); err != nil {
248+
os.Remove(tmp)
249+
}
250+
}
251+
}

0 commit comments

Comments
 (0)