Skip to content

Commit 261a4df

Browse files
authored
fix(memory): efficiency and correctness overhaul for extended + legacy memory (#88)
Consolidates fixes for 21 audited bugs in the memory system. Correctness: - extended: scan-rejected atoms now go through enforceCap before quarantine (regression from #87: quarantine could exceed max_size_mb and wedge the store, since the evictor only evicts trusted atoms) - extended: ReturnAfterBreak summarized the 5 OLDEST atoms (backwards iteration over a newest-first list) instead of the most recent - extended: final recall union was re-sorted by pure RetentionScore, discarding the blended 0.6*similarity+0.4*retention score and the LLM rerank order; the union is now deduped by ID and sorted by composite - extended: extracted atoms dedup by normalized text (refresh CreatedAt, keep higher confidence) instead of accumulating duplicates - extended: eviction now removes association links (no dangling IDs), ForgetAtom works for quarantine-only atoms, UTF-8-safe truncation, quarantine TTL eviction no longer writes under a read lock - loop: skill/episode recall no longer re-runs on every iteration when the result is empty (dedup key was only set on non-empty results) - loop: per-message dedup keys reset between Run/RunWithMessages calls (a repeated identical REPL message previously skipped all memory hooks) - legacy: merge_on_write no longer drops facts on 30-char prefix collisions (new index-based FactStore.ReplaceAt) - legacy: episode Search query cache invalidated on write/promote/prune - legacy: BuildSystemPrompt includes the extended user-state block even when legacy facts are empty, and caches the empty result - legacy: LLM ranker's explicit 'none relevant' is honored instead of being overridden with unranked candidates - legacy: Consolidate enforces the facts char cap and actually trims - extended: fenced/preamble-wrapped LLM JSON (extractor, user-model inference, predictor) now parses instead of silently dropping output Efficiency: - extended: batch per-turn atom adds - one index rebuild per turn instead of one per atom, one assoc.Persist per batch - extended: vector index reuses one embedder across rebuilds so the HTTP embedding cache actually warms (was a full-corpus re-embed over the network per added atom); same sharing for episode dedup + index via embedding.Shared (HTTP backend only; RP stays per-corpus) - extended: recall fan-out cut from ~8 LLM calls to 2 (type filtering over the existing candidate set, no rerank for predicted intents) - extended: one store load per recall query instead of a full atoms.json re-parse per candidate; AnaphoraResolve drops its duplicate search - extended: per-turn extraction + anaphora run async via MemoryManager.RunBackground instead of blocking the ReAct loop - legacy: session-end episode extraction/consolidation are tracked and drained with a bounded 15s wait in Agent.Close, so episodes survive CLI exit instead of dying with the process - extended: background user-model inference guarded against overlap and pending-review duplicates Tests: 30+ new tests across memory/extended/loop/embedding/odek; full suite, -race on memory+loop+embedding, and repo-wide golangci-lint all clean.
1 parent edd80c9 commit 261a4df

34 files changed

Lines changed: 1946 additions & 238 deletions

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ ReAct cycle: observe → think → act → repeat.
9090
- **Context-limit protection**`trimToSurvival` drops oldest messages when approaching the model's context window, keeping the agent functional under extended sessions. Tool messages stay grouped with their parent assistant message.
9191
- **Interaction modes** — engaging (narrated), enhance (persistent), verbose (raw), off.
9292
- Max 300 iterations by default.
93-
- **Post-response async processing** — skill learning and episode extraction run in background goroutines, eliminating the hang after every `odek run`.
93+
- **Post-response async processing** — skill learning, episode extraction, and per-turn extended-memory extraction run in background goroutines tracked by `MemoryManager.RunBackground`; `Agent.Close` drains them via `WaitForBackground` (bounded, ~15s) so the work survives CLI exit without hanging `odek run`.
9494
- **Artifact-aware file search**`search_files` and `multi_grep` skip build/artifact directories (`node_modules`, `vendor`, `.git`, `__pycache__`, `.venv`, etc.) automatically, reducing noise and speeding scans.
9595
- **Semantic session search** — the `session_search` tool uses go-vector RandomProjections + k-NN for semantic similarity search through session content, with a two-tier pipeline: vector index (fast, ~1ms) → deepSearch fallback (exhaustive, slower).
9696
- **Security-first defaults** — the latest hardening closes the high/medium/low findings tracked in `sec_findings.md`: default `non_interactive` is `deny`, project-level `odek.json` cannot redirect backends or hijack delivery, project-level sandbox knobs require explicit operator approval, `~/.odek` trust anchors are protected, WebSocket upgrades require a per-instance CSRF token, and all untrusted content is wrapped before reaching the model. See Security Architecture below for the full list.

cmd/odek/main.go

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1493,9 +1493,11 @@ func run(args []string) error {
14931493
}
14941494

14951495
// ── Session end — extract episode if enough turns ──
1496-
// Run asynchronously so episode extraction does not delay process exit.
1496+
// Run in the background (tracked by the memory manager's WaitGroup) so
1497+
// episode extraction does not delay the response; Agent.Close drains it
1498+
// via WaitForBackground before process exit so it is not silently lost.
14971499
if mm := agent.Memory(); mm != nil && f.Session != nil && *f.Session && sessionID != "" {
1498-
go func() {
1500+
mm.RunBackground(func() {
14991501
store, err := session.NewStore()
15001502
if err == nil {
15011503
latest, err := store.Load(sessionID)
@@ -1505,7 +1507,7 @@ func run(args []string) error {
15051507
mm.OnSessionEndWithProvenance(latest.ID, latest.Turns, msgStrs, prov)
15061508
}
15071509
}
1508-
}()
1510+
})
15091511
}
15101512

15111513
// ── Delivery: send result to default channel ──
@@ -2472,13 +2474,15 @@ func continueCmd(args []string) error {
24722474
fmt.Fprintf(os.Stderr, "odek: session %s saved (%d turns)\n", sess.ID, sess.Turns+1)
24732475

24742476
// ── Session end — extract episode ──
2475-
// Run asynchronously so episode extraction does not delay process exit.
2477+
// Run in the background (tracked by the memory manager's WaitGroup) so
2478+
// episode extraction does not delay the response; Agent.Close drains it
2479+
// via WaitForBackground before process exit so it is not silently lost.
24762480
if mm := agent.Memory(); mm != nil {
2477-
go func() {
2481+
mm.RunBackground(func() {
24782482
msgStrs := makeSessionMessageStrings(sess)
24792483
prov := memory.DeriveProvenance(sess.Messages)
24802484
mm.OnSessionEndWithProvenance(sess.ID, sess.Turns+1, msgStrs, prov)
2481-
}()
2485+
})
24822486
}
24832487

24842488
return nil

cmd/odek/repl.go

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -292,17 +292,19 @@ func replCmd(args []string) error {
292292
}
293293

294294
// Session end — extract episode if enough turns.
295-
// Run asynchronously so episode extraction does not delay process exit.
295+
// Run in the background (tracked by the memory manager's WaitGroup) so
296+
// episode extraction does not delay REPL exit; the deferred Agent.Close
297+
// drains it via WaitForBackground so it is not silently lost.
296298
if mm := agent.Memory(); mm != nil {
297-
go func() {
299+
mm.RunBackground(func() {
298300
messages := sess.GetMessages()
299301
msgStrs := make([]string, 0, len(messages))
300302
for _, m := range messages {
301303
msgStrs = append(msgStrs, m.Role+": "+m.Content)
302304
}
303305
prov := memory.DeriveProvenance(messages)
304306
mm.OnSessionEndWithProvenance(sess.ID, sess.Turns, msgStrs, prov)
305-
}()
307+
})
306308
}
307309

308310
return nil

docs/EXTENDED_MEMORY.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -173,9 +173,9 @@ Extended Memory replaces episode-based recall with semantic search over atom vec
173173
2. Query the go-vector store for the top `semantic_search_top_k * semantic_search_overfetch` candidates.
174174
3. Drop tainted atoms and atoms below `semantic_search_min_score`.
175175
4. Compute a composite score: `0.6 * cosine_similarity + 0.4 * retention_score`.
176-
5. Optionally rerank the candidate set with the memory LLM.
177-
6. If `follow_up_anticipation_enabled` is true, generate predicted intents from the current message, recent messages, and the user model, and union their recall results with the literal-query results (including type-targeted recall for `convention`, `file`, and `error` atoms).
178-
7. Deduplicate, re-rank by retention score, and return the top-K atoms, bounded by `memory_budget_chars`.
176+
5. Optionally rerank the literal query's candidate set with the memory LLM (predicted-intent queries are not reranked).
177+
6. If `follow_up_anticipation_enabled` is true, generate predicted intents from the current message, recent messages, and the user model, and union their recall results with the literal-query results (including type-targeted recall for `convention`, `file`, and `error` atoms, filtered from the same candidate set).
178+
7. Deduplicate by atom ID (keeping the best composite score), re-rank by the composite score, and return the top-K atoms, bounded by `memory_budget_chars`.
179179

180180
### Ranking Formula
181181

@@ -194,7 +194,7 @@ composite_score = 0.6 * cosine_similarity(query_vector, atom_vector)
194194
+ 0.4 * retention_score
195195
```
196196

197-
The final recall result is also bounded by `memory_budget_chars`. Tainted atoms are excluded regardless of score. When predictive intent recall is enabled, results from predicted intents are deduplicated with the literal-query results and then re-ranked by retention score before the top-K cut.
197+
The final recall result is also bounded by `memory_budget_chars`. Tainted atoms are excluded regardless of score. When predictive intent recall is enabled, results from predicted intents are deduplicated with the literal-query results (keeping the best composite score per atom ID) and then re-ranked by the composite score before the top-K cut.
198198

199199
## Predictive Recall
200200

internal/embedding/embedder_test.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,3 +280,28 @@ func TestHTTPEmbedderCacheResetWhenFull(t *testing.T) {
280280
t.Errorf("cache size = %d, want ≤ %d", size, maxEmbedCacheEntries)
281281
}
282282
}
283+
284+
// TestSharedFactory: the Shared factory returns ONE cache-warm instance for
285+
// stateless (HTTP) backends so consumers like episode dedup and the vector
286+
// index rebuild share the text→vector cache, but a FRESH instance per call
287+
// for corpus-fitted (RandomProjections) backends whose Fit state is per-corpus.
288+
func TestSharedFactory(t *testing.T) {
289+
rpFactory := Shared(nil, 0)
290+
if rpFactory() == rpFactory() {
291+
t.Error("rp backend: Shared must return a fresh instance per call (per-corpus Fit state)")
292+
}
293+
294+
srv, _, _ := mockEmbedServer(t)
295+
httpFactory := Shared(&Config{Provider: "http", BaseURL: srv.URL + "/v1", Model: "mock-embed"}, 0)
296+
first, second := httpFactory(), httpFactory()
297+
if first != second {
298+
t.Fatal("http backend: Shared must return the same cache-warm instance")
299+
}
300+
// Sanity: the shared instance works and caches across Fit calls.
301+
if err := first.Fit([]string{"hello world"}); err != nil {
302+
t.Fatal(err)
303+
}
304+
if _, err := second.Embed("hello world"); err != nil {
305+
t.Fatal(err)
306+
}
307+
}

internal/embedding/embedding.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,28 @@ func New(cfg *Config, rpDims int) TextEmbedder {
156156
}
157157
}
158158

159+
// Shared returns an embedder factory like New, except that for stateless
160+
// (HTTP) backends every call yields the SAME instance so its text→vector
161+
// cache is shared across consumers — e.g. episode dedup and the episode
162+
// vector-index rebuild then embed each corpus text once per process instead
163+
// of once per pass. Corpus-fitted backends (RandomProjections) still get a
164+
// FRESH instance per call: their Fit state is per-corpus, so sharing would
165+
// produce degenerate vectors. The HTTP embedder is internally mutex-guarded
166+
// and its Fit only warms the cache, so sharing it across goroutines is safe.
167+
func Shared(cfg *Config, rpDims int) func() TextEmbedder {
168+
if emb := New(cfg, rpDims); isStateless(emb) {
169+
return func() TextEmbedder { return emb }
170+
}
171+
return func() TextEmbedder { return New(cfg, rpDims) }
172+
}
173+
174+
// isStateless reports whether the embedder is safe to share across consumers
175+
// (Fit does not capture corpus state).
176+
func isStateless(emb TextEmbedder) bool {
177+
_, ok := emb.(*httpTextEmbedder)
178+
return ok
179+
}
180+
159181
// ── RandomProjections backend (default) ──────────────────────────────────────
160182

161183
// rpTextEmbedder wraps go-vector RandomProjections behind TextEmbedder,

internal/loop/loop.go

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -559,6 +559,7 @@ func trimToSurvival(msgs []llm.Message) []llm.Message {
559559
// Run executes the loop for a given task and returns the final response.
560560
func (e *Engine) Run(ctx context.Context, task string) (string, error) {
561561
e.memMsgIdx = -1
562+
e.resetDedupKeys()
562563
messages := []llm.Message{
563564
{Role: "user", Content: task},
564565
}
@@ -580,6 +581,7 @@ func (e *Engine) Run(ctx context.Context, task string) (string, error) {
580581
func (e *Engine) RunWithMessages(ctx context.Context, messages []llm.Message) (string, []llm.Message, error) {
581582
// Reset token accounting for this run
582583
e.memMsgIdx = -1
584+
e.resetDedupKeys()
583585
e.TotalInputTokens = 0
584586
e.TotalOutputTokens = 0
585587
e.TotalCacheCreationTokens = 0
@@ -588,6 +590,17 @@ func (e *Engine) RunWithMessages(ctx context.Context, messages []llm.Message) (s
588590
return e.runLoop(ctx, messages)
589591
}
590592

593+
// resetDedupKeys clears the per-message dedup keys so a repeated user
594+
// message in a later run (e.g. the REPL sending the same text twice)
595+
// re-triggers the memory hooks (user-message handler, skill loading,
596+
// episode recall, extended-memory recall).
597+
func (e *Engine) resetDedupKeys() {
598+
e.lastUserMsg = ""
599+
e.lastSkillMsg = ""
600+
e.lastEpiMsg = ""
601+
e.lastExtMsg = ""
602+
}
603+
591604
// trustAllSetter is implemented by approvers (wsApprover, TelegramApprover)
592605
// whose tool-level prompts auto-pass while a batch approval grant is active.
593606
type trustAllSetter interface{ SetTrustAll(bool) }
@@ -661,8 +674,11 @@ func (e *Engine) runLoop(ctx context.Context, messages []llm.Message) (string, [
661674
// Load relevant skills based on latest user input (once per message)
662675
if e.skillLoader != nil {
663676
if userMsg := lastUserMessage(messages); userMsg != "" && userMsg != e.lastSkillMsg {
677+
// Assign the dedup key unconditionally — even when the loader
678+
// finds no match — so a no-match doesn't re-run the (potentially
679+
// slow) skill matcher on every remaining iteration of the turn.
680+
e.lastSkillMsg = userMsg
664681
if skillContext := e.skillLoader(userMsg); skillContext != "" {
665-
e.lastSkillMsg = userMsg
666682
// Inject skill context as a system message right before the user message.
667683
// The skill manager gates NeedsReview/tainted skills, but we treat any
668684
// loaded skill content as externally-sourced and wrap it with the
@@ -704,8 +720,11 @@ func (e *Engine) runLoop(ctx context.Context, messages []llm.Message) (string, [
704720
// Only runs once per new user message (same dedup as skill loading).
705721
if e.episodeCtx != nil {
706722
if userMsg := lastUserMessage(messages); userMsg != "" && userMsg != e.lastEpiMsg {
723+
// Assign the dedup key unconditionally — even when recall finds
724+
// no match — so a no-match doesn't re-run the (potentially slow
725+
// HTTP embed) episode search on every iteration of the turn.
726+
e.lastEpiMsg = userMsg
707727
if episodeContext := e.episodeCtx(userMsg); episodeContext != "" {
708-
e.lastEpiMsg = userMsg
709728
// Episode context comes from past session content and crosses the
710729
// trust boundary; wrap it as untrusted before injecting.
711730
wrappedContext := episodeContext

internal/loop/loop_test.go

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -750,6 +750,132 @@ func TestEngine_SkillLoader_CalledOncePerInput(t *testing.T) {
750750
}
751751
}
752752

753+
// twoIterationServer returns an httptest server whose first response requests
754+
// a tool call and whose second response is the final answer, forcing the loop
755+
// through exactly two iterations with the same user message.
756+
func twoIterationServer(t *testing.T) *httptest.Server {
757+
t.Helper()
758+
callCount := 0
759+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
760+
callCount++
761+
if callCount%2 == 1 {
762+
// Odd iteration: request a tool call
763+
fmt.Fprint(w, `{
764+
"choices":[{
765+
"message":{
766+
"content":"Let me think.",
767+
"tool_calls":[{
768+
"id":"call_1",
769+
"function":{
770+
"name":"echo",
771+
"arguments":"{}"
772+
}
773+
}]
774+
}
775+
}]
776+
}`)
777+
} else {
778+
// Even iteration: final answer
779+
fmt.Fprint(w, `{"choices":[{"message":{"content":"done"}}]}`)
780+
}
781+
}))
782+
t.Cleanup(server.Close)
783+
return server
784+
}
785+
786+
func TestEngine_SkillLoader_NoMatchCalledOncePerInput(t *testing.T) {
787+
// Regression: when the skill loader finds no match it must still record the
788+
// dedup key, otherwise the matcher re-runs on every remaining iteration of
789+
// the turn (each a potentially slow lookup).
790+
skillLoadCount := 0
791+
skillLoader := func(userInput string) string {
792+
skillLoadCount++
793+
return "" // no match
794+
}
795+
796+
server := twoIterationServer(t)
797+
echoTool := &fakeTool{name: "echo", description: "echo", output: "ok"}
798+
registry := tool.NewRegistry([]tool.Tool{echoTool})
799+
client := llm.New(server.URL, "sk-test", "test-model", "", 0, 0)
800+
engine := New(client, registry, 10, "", nil, 0)
801+
engine.SetSkillLoader(skillLoader)
802+
803+
if _, err := engine.Run(context.Background(), "do the task"); err != nil {
804+
t.Fatalf("Run() error: %v", err)
805+
}
806+
if skillLoadCount != 1 {
807+
t.Errorf("SkillLoader called %d times on a no-match, want 1 (dedup key must be set even when empty)", skillLoadCount)
808+
}
809+
}
810+
811+
func TestEngine_EpisodeCtx_NoMatchCalledOncePerInput(t *testing.T) {
812+
// Regression: same dedup contract as the skill loader — a no-match episode
813+
// recall must not re-run the (potentially slow) search every iteration.
814+
episodeSearchCount := 0
815+
episodeCtx := func(userInput string) string {
816+
episodeSearchCount++
817+
return "" // no match
818+
}
819+
820+
server := twoIterationServer(t)
821+
echoTool := &fakeTool{name: "echo", description: "echo", output: "ok"}
822+
registry := tool.NewRegistry([]tool.Tool{echoTool})
823+
client := llm.New(server.URL, "sk-test", "test-model", "", 0, 0)
824+
engine := New(client, registry, 10, "", nil, 0)
825+
engine.SetEpisodeContextFunc(episodeCtx)
826+
827+
if _, err := engine.Run(context.Background(), "do the task"); err != nil {
828+
t.Fatalf("Run() error: %v", err)
829+
}
830+
if episodeSearchCount != 1 {
831+
t.Errorf("episodeCtx called %d times on a no-match, want 1 (dedup key must be set even when empty)", episodeSearchCount)
832+
}
833+
}
834+
835+
func TestEngine_DedupKeysResetBetweenRuns(t *testing.T) {
836+
// Regression: Run/RunWithMessages must reset the per-message dedup keys, so
837+
// a REPL user sending the same text twice still gets the memory hooks
838+
// (skill loading, episode recall, user-message handler) the second time.
839+
skillLoadCount := 0
840+
skillLoader := func(userInput string) string {
841+
skillLoadCount++
842+
return "skill ctx"
843+
}
844+
episodeSearchCount := 0
845+
episodeCtx := func(userInput string) string {
846+
episodeSearchCount++
847+
return "episode ctx"
848+
}
849+
userMsgCount := 0
850+
var userMsgHandler UserMessageHandler = func(ctx context.Context, msg string) {
851+
userMsgCount++
852+
}
853+
854+
server := twoIterationServer(t)
855+
echoTool := &fakeTool{name: "echo", description: "echo", output: "ok"}
856+
registry := tool.NewRegistry([]tool.Tool{echoTool})
857+
client := llm.New(server.URL, "sk-test", "test-model", "", 0, 0)
858+
engine := New(client, registry, 10, "", nil, 0)
859+
engine.SetSkillLoader(skillLoader)
860+
engine.SetEpisodeContextFunc(episodeCtx)
861+
engine.SetUserMessageHandler(userMsgHandler)
862+
863+
for i := 0; i < 2; i++ {
864+
if _, err := engine.Run(context.Background(), "same task twice"); err != nil {
865+
t.Fatalf("Run() error: %v", err)
866+
}
867+
}
868+
if skillLoadCount != 2 {
869+
t.Errorf("SkillLoader called %d times across 2 identical runs, want 2", skillLoadCount)
870+
}
871+
if episodeSearchCount != 2 {
872+
t.Errorf("episodeCtx called %d times across 2 identical runs, want 2", episodeSearchCount)
873+
}
874+
if userMsgCount != 2 {
875+
t.Errorf("userMsgHandler called %d times across 2 identical runs, want 2", userMsgCount)
876+
}
877+
}
878+
753879
func TestEngine_ToolEventHandler(t *testing.T) {
754880
// Verify that ToolEventHandler fires tool_call before and tool_result
755881
// after each tool invocation, and does so live (during the loop).

internal/memory/episode_index_http_test.go

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,3 +177,45 @@ func TestEpisodeIndexRebuildBackoff(t *testing.T) {
177177
t.Error("failedAt should be recent")
178178
}
179179
}
180+
181+
// TestSharedEmbedderWarmsDedupAndRebuild verifies the production wiring
182+
// (NewMemoryManager → embedding.Shared): with an HTTP backend the write-time
183+
// dedup pass and the vector-index rebuild share ONE cache-warm embedder, so
184+
// each corpus text is embedded once per process instead of once per pass.
185+
func TestSharedEmbedderWarmsDedupAndRebuild(t *testing.T) {
186+
resetEpIdxes()
187+
srv, _, texts := mockEmbedServer(t)
188+
dir := t.TempDir()
189+
190+
cfg := DefaultMemoryConfig()
191+
cfg.Embedding = &EmbeddingConfig{Provider: "http", BaseURL: srv.URL + "/v1", Model: "mock-embed"}
192+
mm := NewMemoryManager(dir, nil, cfg)
193+
194+
write := func(id, summary string) {
195+
if err := mm.episodes.WriteWithProvenance(id, summary, 5, EpisodeProvenance{}); err != nil {
196+
t.Fatal(err)
197+
}
198+
}
199+
200+
write("sess-1", "investigated the feline behavior module")
201+
if _, err := mm.episodes.recallByVector("cats", 1); err != nil { // triggers the rebuild
202+
t.Fatal(err)
203+
}
204+
afterFirst := texts.Load()
205+
206+
write("sess-2", "tuned postgres sql indexes")
207+
// The dedup pass fits [sess-1, sess-2] — with a shared embedder only the
208+
// NEW text is a cache miss. Two cold embedders would re-embed both.
209+
if got := texts.Load() - afterFirst; got != 1 {
210+
t.Errorf("dedup embedded %d texts for the second write, want 1 (shared cache)", got)
211+
}
212+
213+
afterSecond := texts.Load()
214+
if _, err := mm.episodes.recallByVector("database", 1); err != nil { // triggers another rebuild
215+
t.Fatal(err)
216+
}
217+
// The rebuild must be fully cache-warm: only the recall query embeds.
218+
if got := texts.Load() - afterSecond; got != 1 {
219+
t.Errorf("rebuild + recall embedded %d texts, want 1 (query only)", got)
220+
}
221+
}

0 commit comments

Comments
 (0)