Skip to content

Commit b914bbd

Browse files
committed
feat: per-turn episode search + 3x memory capacity (Phase 2)
- EpisodeContextFunc: new callback type in loop.Engine, called once per new user message to search past session episodes using the user's input as a query. Injects relevant summaries as a system message. Uses recency-based ranking (no LLM) to avoid recursion. - FormatEpisodeContext: new method on MemoryManager that searches episodes and formats them for injection. Gracefully returns empty string when no episodes exist or memory is disabled. - Memory capacity increased from 4K to 12K chars: User facts: 1,500 → 4,000 chars Env facts: 2,500 → 8,000 chars - Wired per-turn episode search in odek.New() alongside the existing skill loader and memory refresh callbacks. This means odek will now automatically recall relevant past sessions when the user references them — no manual memory(search=...) needed.
1 parent 5e7de3e commit b914bbd

5 files changed

Lines changed: 75 additions & 6 deletions

File tree

internal/loop/loop.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,12 @@ import (
1818
// (formatted skill content) to inject, or empty string if no skills match.
1919
type SkillLoader func(userInput string) string
2020

21+
// EpisodeContextFunc is an optional callback that the loop engine calls
22+
// before each LLM invocation to discover relevant past session episodes.
23+
// The callback receives the latest user input as a search query and returns
24+
// formatted episode context to inject, or empty string if nothing matches.
25+
type EpisodeContextFunc func(userInput string) string
26+
2127
// ToolEventHandler is an optional callback invoked for each tool execution
2228
// during the agent loop — fires before (tool_call) and after (tool_result)
2329
// each tool invocation. Used by the WebUI for live streaming of tool events.
@@ -54,6 +60,7 @@ type Engine struct {
5460
skillLoader SkillLoader // optional: loads matching skills
5561
lastSkillMsg string // last user message that triggered skill loading (dedup)
5662
skillVerbose bool // show full skill banners (default: condensed)
63+
episodeCtx EpisodeContextFunc // optional: per-turn episode search
5764

5865
toolEventHandler ToolEventHandler // optional: fires during tool execution
5966

@@ -99,6 +106,12 @@ func New(client *llm.Client, registry *tool.Registry, maxIterations int, systemM
99106
// SetSkillLoader sets the optional skill loader callback.
100107
func (e *Engine) SetSkillLoader(sl SkillLoader) { e.skillLoader = sl }
101108

109+
// SetEpisodeContextFunc sets the optional per-turn episode search callback.
110+
// When set, it is called once per new user message to search for relevant
111+
// past session episodes. The returned context is injected as a system
112+
// message before the LLM invocation.
113+
func (e *Engine) SetEpisodeContextFunc(ef EpisodeContextFunc) { e.episodeCtx = ef }
114+
102115
// SetSkillVerbose controls whether skill loading shows full banners (true)
103116
// or condensed markers (false, default). Condensed saves context window space.
104117
func (e *Engine) SetSkillVerbose(verbose bool) { e.skillVerbose = verbose }
@@ -333,6 +346,29 @@ func (e *Engine) runLoop(ctx context.Context, messages []llm.Message) (string, [
333346
}
334347
}
335348

349+
// Search relevant past session episodes based on latest user input.
350+
// Only runs once per new user message (same dedup as skill loading).
351+
if e.episodeCtx != nil {
352+
if userMsg := lastUserMessage(messages); userMsg != "" && userMsg != e.lastSkillMsg {
353+
if episodeContext := e.episodeCtx(userMsg); episodeContext != "" {
354+
// Inject episode context as a system message before the user message
355+
insertIdx := len(messages)
356+
for j := len(messages) - 1; j >= 0; j-- {
357+
if messages[j].Role == "system" && j != 0 {
358+
insertIdx = j + 1
359+
break
360+
}
361+
}
362+
epMsg := llm.Message{Role: "system", Content: episodeContext}
363+
newMsgs := make([]llm.Message, 0, len(messages)+1)
364+
newMsgs = append(newMsgs, messages[:insertIdx]...)
365+
newMsgs = append(newMsgs, epMsg)
366+
newMsgs = append(newMsgs, messages[insertIdx:]...)
367+
messages = newMsgs
368+
}
369+
}
370+
}
371+
336372
// Refresh memory content before each LLM call so the agent sees
337373
// the latest facts even if it mutated memory during this session.
338374
if e.memoryPromptFunc != nil {

internal/memory/facts.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,8 @@ const (
3636

3737
// Default character caps for fact files.
3838
const (
39-
defaultFactsLimitUser = 1500
40-
defaultFactsLimitEnv = 2500
39+
defaultFactsLimitUser = 4000
40+
defaultFactsLimitEnv = 8000
4141
)
4242

4343
// entrySep is the delimiter between entries in a fact file.

internal/memory/facts_test.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -236,11 +236,11 @@ func TestFactStore_NewFactStoreZeroCaps(t *testing.T) {
236236
// Zero caps should use defaults
237237
dir := t.TempDir()
238238
fs := NewFactStore(dir, 0, 0)
239-
if fs.capUser != 1500 {
240-
t.Errorf("expected default capUser 1500, got %d", fs.capUser)
239+
if fs.capUser != 4000 {
240+
t.Errorf("expected default capUser 4000, got %d", fs.capUser)
241241
}
242-
if fs.capEnv != 2500 {
243-
t.Errorf("expected default capEnv 2500, got %d", fs.capEnv)
242+
if fs.capEnv != 8000 {
243+
t.Errorf("expected default capEnv 8000, got %d", fs.capEnv)
244244
}
245245
}
246246

internal/memory/memory.go

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -426,6 +426,31 @@ func (m *MemoryManager) SearchEpisodes(query string, limit int) ([]EpisodeMeta,
426426
return m.episodes.Search(query, limit)
427427
}
428428

429+
// FormatEpisodeContext searches episodes with a recency-based ranker
430+
// (no LLM — safe for per-turn use without recursion risk) and returns
431+
// formatted context to inject as a system message. Returns empty string
432+
// if no episodes found or memory is disabled.
433+
func (m *MemoryManager) FormatEpisodeContext(query string) string {
434+
if m.cfg.Enabled == nil || !*m.cfg.Enabled {
435+
return ""
436+
}
437+
438+
episodes, err := m.episodes.Search(query, 3)
439+
if err != nil || len(episodes) == 0 {
440+
return ""
441+
}
442+
443+
var b strings.Builder
444+
b.WriteString("\n═══ RELEVANT PAST SESSIONS ═══\n")
445+
b.WriteString("Summaries of past sessions related to the current task:\n")
446+
for _, ep := range episodes {
447+
fmt.Fprintf(&b, "• [%s] (%d turns): %s\n", ep.SessionID, ep.Turns, ep.Summary)
448+
}
449+
b.WriteString("─────────────────────────────────\n")
450+
b.WriteString("Use this context to recall past decisions, fixes, and discussions.\n")
451+
return b.String()
452+
}
453+
429454
// ── System Prompt Builder ────────────────────────────────────────────
430455

431456
// BuildSystemPrompt returns the memory section to inject into the system

odek.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -520,6 +520,14 @@ func New(cfg Config) (*Agent, error) {
520520
engine.SetIterationCallback(cfg.IterationCallback)
521521
}
522522

523+
// Wire per-turn episode search — searches past session episodes
524+
// using the user's message as a query, then injects relevant summaries.
525+
// Uses recency-based ranking (no LLM) to avoid recursion in the loop.
526+
// Only active when memory is enabled.
527+
engine.SetEpisodeContextFunc(func(userInput string) string {
528+
return memoryManager.FormatEpisodeContext(userInput)
529+
})
530+
523531
agent.engine = engine
524532
agent.registry = registry
525533
agent.sandboxCleanup = cfg.SandboxCleanup

0 commit comments

Comments
 (0)