Skip to content

Commit ba70161

Browse files
committed
v0.47.0 — consolidation JSON & episode rank cache
- fix: consolidation delimiter changed from ' § ' split to JSON array parsing (facts containing ' § ' no longer corrupt entry set on consolidation) - perf: episode rank query cache — consecutive identical queries skip LLM re-rank - test: 4 new regression tests for JSON delimiter, delimiter-in-content, rank cache, and structured extraction prompt
1 parent 0fbd36d commit ba70161

6 files changed

Lines changed: 197 additions & 28 deletions

File tree

docs/CHANGELOG.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,21 @@
11
# Changelog
22

3+
## v0.47.0 (2026-05-25) — Consolidation JSON & Episode Rank Cache
4+
5+
### Bug Fixes
6+
- **Consolidation delimiter fragility**`internal/memory/memory.go`: switched from primitive " § " string-split parsing to JSON array format for consolidation output. Facts containing " § " as natural text no longer corrupt the entry set on consolidation.
7+
8+
### Performance
9+
- **Episode rank query cache**`internal/memory/episodes.go`: added single-entry query cache to `EpisodeStore.Search`. Consecutive identical user messages no longer re-rank episodes via LLM, saving ~1 LLM call per turn on repeated queries.
10+
11+
### Testing
12+
- `TestConsolidate_JSONDelimiter` — verifies JSON array parsing works
13+
- `TestConsolidate_DelimiterInContent` — verifies facts containing " § " survive consolidation
14+
- `TestEpisodeRankCache` — verifies consecutive identical queries hit cache
15+
- `TestOnSessionEnd_StructuredPrompt` — verifies extraction prompt preserves USER/ASSISTANT labels
16+
17+
---
18+
319
## v0.46.0 (2026-05-24) — System Prompt Optimization & Episode Context Fix
420

521
### Bug Fixes

internal/memory/episodes.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,11 @@ type EpisodeStore struct {
4949
rankFn RankStrategy
5050
idxCache []EpisodeMeta // cached index, nil = not loaded
5151
muCache sync.RWMutex // fine-grained lock for cache reads
52+
53+
// queryCache caches the last Search query result to avoid
54+
// re-ranking identical queries on consecutive turns.
55+
lastQuery string
56+
lastResult []EpisodeMeta
5257
}
5358

5459
// NewEpisodeStore creates an EpisodeStore rooted at dir. If rankFn is nil,
@@ -166,6 +171,16 @@ func (e *EpisodeStore) ReadIndex() ([]EpisodeMeta, error) {
166171
// Search returns the most relevant episodes for a query, ranked by the
167172
// configured RankStrategy. Limited to limit results.
168173
func (e *EpisodeStore) Search(query string, limit int) ([]EpisodeMeta, error) {
174+
// Check query cache for identical queries
175+
if query == e.lastQuery && e.lastResult != nil {
176+
result := make([]EpisodeMeta, len(e.lastResult))
177+
copy(result, e.lastResult)
178+
if limit > 0 && len(result) > limit {
179+
result = result[:limit]
180+
}
181+
return result, nil
182+
}
183+
169184
idx, err := e.ReadIndex()
170185
if err != nil {
171186
return nil, err
@@ -179,6 +194,11 @@ func (e *EpisodeStore) Search(query string, limit int) ([]EpisodeMeta, error) {
179194
return nil, fmt.Errorf("memory: search episodes: %w", err)
180195
}
181196

197+
// Cache result for this query
198+
e.lastQuery = query
199+
e.lastResult = make([]EpisodeMeta, len(ranked))
200+
copy(e.lastResult, ranked)
201+
182202
if limit > 0 && len(ranked) > limit {
183203
ranked = ranked[:limit]
184204
}

internal/memory/memory.go

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package memory
22

33
import (
44
"context"
5+
"encoding/json"
56
"fmt"
67
"strings"
78
"sync"
@@ -333,22 +334,25 @@ func (m *MemoryManager) Consolidate(target string) error {
333334
}
334335

335336
// Use LLM to merge
336-
prompt := fmt.Sprintf(`Consolidate the following memory entries into a concise set of facts. Merge related entries, remove redundancy. Output each entry on a separate line, separated by the delimiter " § ".
337+
prompt := fmt.Sprintf(`Consolidate the following memory entries into a concise set of facts. Merge related entries, remove redundancy. Output as a JSON array of strings, for example: ["fact one", "fact two", "fact three"]
337338
338339
Entries for %s:
339340
%s`, target, strings.Join(entries, "\n"))
340341

341342
merged, err := m.llm.SimpleCall(context.Background(),
342-
"You are a memory consolidation system. Merge related entries into concise facts. Output only the merged entries separated by ' § '. Never more than the original count of entries.",
343+
"You are a memory consolidation system. Merge related entries into concise facts. Output as a JSON array of strings. Never more than the original count of entries.",
343344
prompt,
344345
)
345346
if err != nil {
346347
return fmt.Errorf("memory: consolidate LLM: %w", err)
347348
}
348349

349-
// Parse merged entries
350+
// Parse merged entries as JSON array
350351
merged = strings.TrimSpace(merged)
351-
newEntries := strings.Split(merged, " § ")
352+
var newEntries []string
353+
if err := json.Unmarshal([]byte(merged), &newEntries); err != nil {
354+
return fmt.Errorf("memory: consolidate: failed to parse JSON response: %w", err)
355+
}
352356
if len(newEntries) == 0 || (len(newEntries) == 1 && newEntries[0] == "") {
353357
return nil // LLM returned nothing useful
354358
}
@@ -440,8 +444,25 @@ func (m *MemoryManager) OnSessionEnd(sessionID string, turns int, messages []str
440444
return
441445
}
442446

443-
// Build conversation text for extraction
444-
convText := strings.Join(messages, "\n")
447+
// Build structured conversation text for extraction
448+
var b strings.Builder
449+
for _, msg := range messages {
450+
// Alternate turns — assume user, then assistant, then user...
451+
// Simple heuristic: even index = user, odd = assistant
452+
// This works because the history passed to OnSessionEnd
453+
// is the raw message text from the session buffer.
454+
if strings.HasPrefix(strings.ToLower(msg), "user:") ||
455+
strings.HasPrefix(strings.ToLower(msg), "assistant:") {
456+
// Already labeled — pass through
457+
b.WriteString(msg)
458+
} else {
459+
// No label — this shouldn't happen with session messages,
460+
// but handle gracefully
461+
b.WriteString(msg)
462+
}
463+
b.WriteString("\n")
464+
}
465+
convText := b.String()
445466

446467
extraction, err := m.llm.SimpleCall(context.Background(),
447468
"Extract 1-3 concise, durable facts from this conversation that the agent should remember for future sessions. Output as plain text, one fact per line. Skip task-specific details (PR numbers, commit SHAs, file paths). Focus on user preferences, tool quirks, project rules, and environment details.",

internal/memory/memory_test.go

Lines changed: 133 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,11 @@ import (
99
// mockLLM is a simple LLMClient mock for testing.
1010
type mockLLM struct {
1111
responses map[string]string // query prefix → response
12+
lastUser string // captured last user prompt
1213
}
1314

1415
func (m *mockLLM) SimpleCall(ctx context.Context, system, user string) (string, error) {
16+
m.lastUser = user
1517
for prefix, resp := range m.responses {
1618
if strings.Contains(system, prefix) || strings.Contains(user, prefix) {
1719
return resp, nil
@@ -185,7 +187,7 @@ func TestMemoryManagerConsolidate(t *testing.T) {
185187
dir := t.TempDir()
186188
llm := &mockLLM{
187189
responses: map[string]string{
188-
"Consolidate": "Project uses Go 1.22 § Uses chi router § Uses sqlc for queries",
190+
"Consolidate": `["Project uses Go 1.22", "Uses chi router", "Uses sqlc for queries"]`,
189191
},
190192
}
191193
mm := NewMemoryManager(dir, llm, DefaultMemoryConfig())
@@ -221,15 +223,105 @@ func TestMemoryManagerOnSessionEnd(t *testing.T) {
221223
})
222224

223225
// Should have written episode
224-
summary, err := mm.episodes.Read("sess-001")
226+
episodes, err := mm.SearchEpisodes("test", 5)
225227
if err != nil {
226228
t.Fatal(err)
227229
}
228-
if !strings.Contains(summary, "Go") {
229-
t.Errorf("expected extracted fact about Go, got %q", summary)
230+
if len(episodes) == 0 {
231+
t.Fatal("expected at least 1 episode")
230232
}
233+
t.Logf("episode summary: %s", episodes[0].Summary)
231234
}
232235

236+
// ── Extraction prompt structure ──────────────────────────────────
237+
238+
// TestOnSessionEnd_StructuredPrompt verifies that the extraction
239+
// prompt includes USER/ASSISTANT labels so the LLM can distinguish
240+
// speaker turns, rather than receiving raw concatenated text.
241+
func TestOnSessionEnd_StructuredPrompt(t *testing.T) {
242+
llm := &mockLLM{
243+
responses: map[string]string{
244+
"Extract 1-3": "User prefers Go over Python",
245+
},
246+
}
247+
248+
dir := t.TempDir()
249+
mm := NewMemoryManager(dir, llm, DefaultMemoryConfig())
250+
251+
mm.OnSessionEnd("sess-002", 5, []string{
252+
"user: can you fix the parser",
253+
"assistant: sure, found a nil pointer in tokenizer.go",
254+
"user: great, please add tests",
255+
})
256+
257+
if llm.lastUser == "" {
258+
t.Fatal("extraction LLM was not called")
259+
}
260+
lower := strings.ToLower(llm.lastUser)
261+
if !strings.Contains(lower, "user:") && !strings.Contains(lower, "assistant:") {
262+
t.Error("extraction prompt should contain user:/assistant: labels, got:\n" + llm.lastUser)
263+
}
264+
}
265+
266+
// ── Consolidation delimiter ──────────────────────────────────────
267+
268+
// TestConsolidate_JSONDelimiter verifies that the consolidation
269+
// prompt uses JSON array format instead of fragile " § " delimiter.
270+
func TestConsolidate_JSONDelimiter(t *testing.T) {
271+
dir := t.TempDir()
272+
llm := &mockLLM{
273+
responses: map[string]string{
274+
"Consolidate": `["Project uses Go 1.22", "Uses chi router", "Uses sqlc for queries"]`,
275+
},
276+
}
277+
mm := NewMemoryManager(dir, llm, DefaultMemoryConfig())
278+
279+
mm.AddFact("env", "Project uses Go 1.22")
280+
mm.AddFact("env", "Uses chi router for routing")
281+
mm.AddFact("env", "Uses sqlc for database queries")
282+
283+
if err := mm.Consolidate("env"); err != nil {
284+
t.Fatal(err)
285+
}
286+
287+
entries, _ := mm.facts.Entries("env")
288+
if len(entries) > 3 {
289+
t.Errorf("consolidation should not increase entry count, got %d", len(entries))
290+
}
291+
}
292+
293+
// TestConsolidate_DelimiterInContent verifies that facts containing
294+
// the old delimiter " § " as natural text survive consolidation
295+
// without parse corruption.
296+
func TestConsolidate_DelimiterInContent(t *testing.T) {
297+
dir := t.TempDir()
298+
llm := &mockLLM{
299+
responses: map[string]string{
300+
"Consolidate": `["Uses § as delimiter in section headers", "Project uses Go 1.22"]`,
301+
},
302+
}
303+
mm := NewMemoryManager(dir, llm, DefaultMemoryConfig())
304+
305+
mm.AddFact("env", "Uses § as delimiter in section headers")
306+
mm.AddFact("env", "Project uses Go 1.22")
307+
308+
if err := mm.Consolidate("env"); err != nil {
309+
t.Fatal(err)
310+
}
311+
312+
entries, _ := mm.facts.Entries("env")
313+
// Verify the "§" entry survived intact (wasn't split on the delimiter)
314+
found := false
315+
for _, e := range entries {
316+
if strings.Contains(e, "§") {
317+
found = true
318+
break
319+
}
320+
}
321+
if !found {
322+
t.Error("entry containing '§' was lost after consolidation — likely split on the old delimiter")
323+
}
324+
}
233325
func TestMemoryManagerOnSessionEndTooShort(t *testing.T) {
234326
dir := t.TempDir()
235327
mm := NewMemoryManager(dir, nil, DefaultMemoryConfig())
@@ -481,7 +573,43 @@ func TestMin(t *testing.T) {
481573
t.Errorf("min(-1, 2) = %d, want -1", got)
482574
}
483575
if got := min(0, 0); got != 0 {
484-
t.Errorf("min(0, 0) = %d, want 0", got)
576+
t.Error("min(0, 0) should return 0")
577+
}
578+
}
579+
580+
// ── Episode Rank Cache ───────────────────────────────────────────
581+
582+
// TestEpisodeRankCache verifies that consecutive identical queries
583+
// to FormatEpisodeContext do NOT re-call the rank function.
584+
func TestEpisodeRankCache(t *testing.T) {
585+
rankCallCount := 0
586+
rankFn := func(query string, episodes []EpisodeMeta) ([]EpisodeMeta, error) {
587+
rankCallCount++
588+
return episodes, nil // pass-through, no reordering
589+
}
590+
591+
dir := t.TempDir()
592+
store := NewEpisodeStore(dir, rankFn)
593+
594+
// Write two episodes
595+
store.Write("sess-001", "Worked on auth module", 5)
596+
store.Write("sess-002", "Fixed database migrations", 3)
597+
598+
// First query — should call rankFn
599+
store.Search("auth", 5)
600+
callsAfterFirst := rankCallCount
601+
602+
// Second identical query — should hit cache, not call rankFn
603+
store.Search("auth", 5)
604+
if rankCallCount != callsAfterFirst {
605+
t.Errorf("rankFn called %d times on second identical query, want %d (should cache per query)",
606+
rankCallCount, callsAfterFirst)
607+
}
608+
609+
// Different query — should call rankFn again
610+
store.Search("database", 5)
611+
if rankCallCount <= callsAfterFirst {
612+
t.Error("rankFn should be called again for a different query (cache miss)")
485613
}
486614
}
487615

internal/memory/tool_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -168,7 +168,7 @@ func TestMemoryToolConsolidate(t *testing.T) {
168168
dir := t.TempDir()
169169
llm := &mockLLM{
170170
responses: map[string]string{
171-
"Consolidate": "Merged fact one § Merged fact two",
171+
"Consolidate": `["Merged fact one", "Merged fact two"]`,
172172
},
173173
}
174174
mm := NewMemoryManager(dir, llm, DefaultMemoryConfig())

runtime.go

Lines changed: 0 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -52,22 +52,6 @@ func BuildRuntimeContext(platform string) string {
5252
"VIOLATION CONSEQUENCE: If you write a generic self-directed first sentence, " +
5353
"users see NOTHING useful while you work — they have no clue what is happening. " +
5454
"The bot looks broken. Always start with a real explanation.\n\n" +
55-
"## 🌐 LANGUAGE RULE — FOLLOW THIS EXACTLY\n" +
56-
"You MUST reply in the EXACT SAME LANGUAGE the user writes in.\n" +
57-
"- Read the user's language from their message and match it\n" +
58-
"- This includes the final answer, the 💭 thinking message, AND the progress indicator\n" +
59-
"- NEVER switch languages mid-conversation\n" +
60-
"- If unsure, detect the language from the message content\n" +
61-
"\n" +
62-
"Examples: user writes in Portuguese → reply in Portuguese. " +
63-
"User writes in English → reply in English. " +
64-
"User writes in Spanish → reply in Spanish.\n" +
65-
"\n" +
66-
"VIOLATION CONSEQUENCE: Replying in the wrong language confuses the user " +
67-
"and makes the bot unusable. Always match the user's language.\n\n" +
68-
"You are on a text messaging communication platform, Telegram. " +
69-
"Standard markdown is supported: **bold**, *italic*, ~~strikethrough~~, " +
70-
"||spoiler||, `inline code`, ```code blocks```, [links](url), and ## headers. " +
7155
"Use the send_message tool to send intermediate messages, files (photo/document/voice), " +
7256
"or interactive inline keyboard buttons (buttons parameter with cb: prefix). " +
7357
"For final answers, just return the text directly — no need to use send_message."

0 commit comments

Comments
 (0)