From 892e755524b7cbf65c6f2747dd581d0fe21e7c18 Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso Date: Wed, 22 Jul 2026 14:32:46 +0200 Subject: [PATCH 1/7] feat(schedule): wire memory into headless scheduled jobs runTaskHeadless built the agent without MemoryDir/MemoryConfig, so scheduled jobs could not analyze memory or past sessions - the prerequisite for memory-driven proactive messages. Mirrors the interactive-run wiring (cmd/odek/main.go). Test proves a pre-seeded extended-memory atom reaches the model context in a headless run. --- cmd/odek/schedule.go | 5 +++ cmd/odek/schedule_memory_test.go | 74 ++++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+) create mode 100644 cmd/odek/schedule_memory_test.go diff --git a/cmd/odek/schedule.go b/cmd/odek/schedule.go index 665fd76..476f753 100644 --- a/cmd/odek/schedule.go +++ b/cmd/odek/schedule.go @@ -720,6 +720,11 @@ func runTaskHeadless(ctx context.Context, resolved config.ResolvedConfig, system IterationCallback: func(info loop.IterationInfo) { lastInfo = info }, Guard: injectionGuard, GuardConfig: resolved.Guard, + // Scheduled jobs may analyze memory and past sessions (e.g. proactive + // nudges over open goals), so they get the same memory wiring as + // interactive runs (see cmd/odek/main.go). + MemoryDir: expandHome("~/.odek/memory"), + MemoryConfig: resolved.Memory, }) if err != nil { return "", 0, err diff --git a/cmd/odek/schedule_memory_test.go b/cmd/odek/schedule_memory_test.go new file mode 100644 index 0000000..0ddcaca --- /dev/null +++ b/cmd/odek/schedule_memory_test.go @@ -0,0 +1,74 @@ +package main + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" + + "github.com/BackendStack21/odek/internal/config" + "github.com/BackendStack21/odek/internal/memory/extended" +) + +// TestRunTaskHeadless_MemoryWired proves that scheduled (headless) jobs get +// the same memory wiring as interactive runs: a pre-seeded extended-memory +// atom must reach the model's context. Regression test for the gap where +// runTaskHeadless built the agent without MemoryDir/MemoryConfig. +func TestRunTaskHeadless_MemoryWired(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + + // Seed an extended-memory atom in the (temp) global memory dir. + extCfg := extended.DefaultConfig() + enabled := true + extCfg.Enabled = &enabled + em := extended.New(home+"/.odek/memory/extended", nil, extCfg) + if err := em.AddAtom(context.Background(), extended.MemoryAtom{ + Text: "The user's favorite editor is Emacs.", + SourceClass: extended.SourceUserApproved, + Type: extended.TypePreference, + }); err != nil { + t.Fatalf("seed atom: %v", err) + } + + // Mock LLM: capture the chat request body, answer with a final response. + var sawAtom atomic.Bool + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/models") { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"data": []map[string]any{}}) + return + } + body, _ := io.ReadAll(r.Body) + if strings.Contains(string(body), "favorite editor is Emacs") { + sawAtom.Store(true) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "choices": []map[string]any{{ + "message": map[string]any{"role": "assistant", "content": "ok"}, + "finish_reason": "stop", + }}, + "usage": map[string]any{"prompt_tokens": 10, "completion_tokens": 5}, + }) + })) + defer srv.Close() + + resolved := config.LoadConfig(config.CLIFlags{}) + resolved.BaseURL = srv.URL + "/v1" + resolved.APIKey = "test" + resolved.Model = "mock-model" + resolved.Memory.Extended = &extCfg + + _, _, err := runTaskHeadless(context.Background(), resolved, "you are a test agent", "what is my favorite editor?", nil) + if err != nil { + t.Fatalf("runTaskHeadless: %v", err) + } + if !sawAtom.Load() { + t.Error("seeded memory atom did not reach the model context — memory is not wired into headless runs") + } +} From 4b78f1adc22ed3889a3718b46607f178b84d069a Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso Date: Wed, 22 Jul 2026 15:03:40 +0200 Subject: [PATCH 2/7] feat(memory): proactive engagement core - follow-up capture, open loops, nudges engine - recall-time predicted intents are now captured (>=0.6 confidence, cap 3) instead of discarded, exposed via ExtendedMemory.LastFollowUps and MemoryManager.FollowUpSuggestions - zero extra LLM cost - the extractor now emits 'question' atoms (unanswered user questions) and 'goal' atoms (stated intentions); new OpenLoops query surfaces them as the blind-spot data source - new ProactiveNudges engine: one LLM call synthesizes concise nudges from open loops, stale goals, and user-model focus; persisted anti-annoyance ledger (nudges.json) enforces an opt-in master switch (default off), a daily cap (1), and a per-kind cooldown (24h); preview (ProactiveNudges) never consumes the budget; all failures degrade to empty so proactivity can never break a turn - config: 6 new memory.extended keys with env plumbing --- internal/config/loader.go | 42 ++ internal/config/loader_test.go | 33 ++ internal/memory/extended/config.go | 130 +++-- internal/memory/extended/extended_memory.go | 66 +++ internal/memory/extended/extractor.go | 2 + internal/memory/extended/nudges.go | 301 ++++++++++++ internal/memory/extended/proactive_test.go | 516 ++++++++++++++++++++ internal/memory/extended/recall.go | 29 ++ internal/memory/memory.go | 40 ++ internal/memory/memory_test.go | 15 + 10 files changed, 1124 insertions(+), 50 deletions(-) create mode 100644 internal/memory/extended/nudges.go create mode 100644 internal/memory/extended/proactive_test.go diff --git a/internal/config/loader.go b/internal/config/loader.go index 2bb3e99..b5b5119 100644 --- a/internal/config/loader.go +++ b/internal/config/loader.go @@ -1103,6 +1103,48 @@ func LoadConfig(cli CLIFlags) ResolvedConfig { cfg.Memory.Extended = ensureExtended(cfg.Memory.Extended) cfg.Memory.Extended.FollowUpAnticipationEnabled = v } + if v := envBool("MEMORY_EXTENDED_FOLLOW_UP_SUGGESTIONS_ENABLED"); v != nil { + if cfg.Memory == nil { + cfg.Memory = &memory.MemoryConfig{} + } + cfg.Memory.Extended = ensureExtended(cfg.Memory.Extended) + cfg.Memory.Extended.FollowUpSuggestionsEnabled = v + } + if v := envFloat("MEMORY_EXTENDED_FOLLOW_UP_SUGGESTION_MIN_CONFIDENCE"); v > 0 { + if cfg.Memory == nil { + cfg.Memory = &memory.MemoryConfig{} + } + cfg.Memory.Extended = ensureExtended(cfg.Memory.Extended) + cfg.Memory.Extended.FollowUpSuggestionMinConfidence = float32(v) + } + if v := envBool("MEMORY_EXTENDED_PROACTIVE_NUDGES_ENABLED"); v != nil { + if cfg.Memory == nil { + cfg.Memory = &memory.MemoryConfig{} + } + cfg.Memory.Extended = ensureExtended(cfg.Memory.Extended) + cfg.Memory.Extended.ProactiveNudgesEnabled = v + } + if v := envInt("MEMORY_EXTENDED_NUDGE_MAX_PER_DAY"); v > 0 { + if cfg.Memory == nil { + cfg.Memory = &memory.MemoryConfig{} + } + cfg.Memory.Extended = ensureExtended(cfg.Memory.Extended) + cfg.Memory.Extended.NudgeMaxPerDay = v + } + if v := envInt("MEMORY_EXTENDED_NUDGE_COOLDOWN_HOURS"); v > 0 { + if cfg.Memory == nil { + cfg.Memory = &memory.MemoryConfig{} + } + cfg.Memory.Extended = ensureExtended(cfg.Memory.Extended) + cfg.Memory.Extended.NudgeCooldownHours = v + } + if v := envInt("MEMORY_EXTENDED_NUDGE_STALE_GOAL_DAYS"); v > 0 { + if cfg.Memory == nil { + cfg.Memory = &memory.MemoryConfig{} + } + cfg.Memory.Extended = ensureExtended(cfg.Memory.Extended) + cfg.Memory.Extended.NudgeStaleGoalDays = v + } // Guard env overrides if v := envString("GUARD_PROVIDER"); v != "" { diff --git a/internal/config/loader_test.go b/internal/config/loader_test.go index 51d78e3..2e5d682 100644 --- a/internal/config/loader_test.go +++ b/internal/config/loader_test.go @@ -1557,6 +1557,39 @@ func TestLoadConfig_ExtendedMemoryEnv(t *testing.T) { } } +func TestLoadConfig_ExtendedMemoryProactiveEnv(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + t.Setenv("ODEK_MEMORY_EXTENDED_FOLLOW_UP_SUGGESTIONS_ENABLED", "false") + t.Setenv("ODEK_MEMORY_EXTENDED_FOLLOW_UP_SUGGESTION_MIN_CONFIDENCE", "0.75") + t.Setenv("ODEK_MEMORY_EXTENDED_PROACTIVE_NUDGES_ENABLED", "true") + t.Setenv("ODEK_MEMORY_EXTENDED_NUDGE_MAX_PER_DAY", "3") + t.Setenv("ODEK_MEMORY_EXTENDED_NUDGE_COOLDOWN_HOURS", "12") + t.Setenv("ODEK_MEMORY_EXTENDED_NUDGE_STALE_GOAL_DAYS", "14") + cfg := LoadConfig(CLIFlags{}) + if cfg.Memory.Extended == nil { + t.Fatal("Extended memory config not loaded from env") + } + ext := cfg.Memory.Extended + if ext.FollowUpSuggestionsEnabled == nil || *ext.FollowUpSuggestionsEnabled { + t.Error("FollowUpSuggestionsEnabled should be false") + } + if ext.FollowUpSuggestionMinConfidence != 0.75 { + t.Errorf("FollowUpSuggestionMinConfidence = %v, want 0.75", ext.FollowUpSuggestionMinConfidence) + } + if ext.ProactiveNudgesEnabled == nil || !*ext.ProactiveNudgesEnabled { + t.Error("ProactiveNudgesEnabled should be true") + } + if ext.NudgeMaxPerDay != 3 { + t.Errorf("NudgeMaxPerDay = %d, want 3", ext.NudgeMaxPerDay) + } + if ext.NudgeCooldownHours != 12 { + t.Errorf("NudgeCooldownHours = %d, want 12", ext.NudgeCooldownHours) + } + if ext.NudgeStaleGoalDays != 14 { + t.Errorf("NudgeStaleGoalDays = %d, want 14", ext.NudgeStaleGoalDays) + } +} + func TestLoadConfig_ExtendedMemoryCLIOverridesEnv(t *testing.T) { t.Setenv("HOME", t.TempDir()) t.Setenv("ODEK_MEMORY_EXTENDED_MAX_SIZE_MB", "200") diff --git a/internal/memory/extended/config.go b/internal/memory/extended/config.go index 273e051..5211c03 100644 --- a/internal/memory/extended/config.go +++ b/internal/memory/extended/config.go @@ -16,32 +16,38 @@ import ( // Config controls the Extended Memory subsystem. type Config struct { - Enabled *bool `json:"enabled,omitempty"` - MaxSizeMB int `json:"max_size_mb,omitempty"` - SemanticSearchTopK int `json:"semantic_search_top_k,omitempty"` - SemanticSearchOverfetch int `json:"semantic_search_overfetch,omitempty"` - SemanticSearchMinScore float32 `json:"semantic_search_min_score,omitempty"` - SemanticSearchRerank *bool `json:"semantic_search_rerank,omitempty"` - AtomMaxChars int `json:"atom_max_chars,omitempty"` - MemoryBudgetChars int `json:"memory_budget_chars,omitempty"` - DecayHalfLifeDays int `json:"decay_half_life_days,omitempty"` - QuarantineTTLDays int `json:"quarantine_ttl_days,omitempty"` - EvictionPolicy string `json:"eviction_policy,omitempty"` - PredictiveIntents int `json:"predictive_intents,omitempty"` - AutoExtractPerTurn *bool `json:"auto_extract_per_turn,omitempty"` - InferUserState *bool `json:"infer_user_state,omitempty"` - UserStateTurnInterval int `json:"user_state_turn_interval,omitempty"` - UserStateMaxPending int `json:"user_state_max_pending,omitempty"` - AssociationsEnabled *bool `json:"associations_enabled,omitempty"` - AssociationSemanticTopK int `json:"association_semantic_top_k,omitempty"` - SemanticDedupThreshold *float32 `json:"semantic_dedup_threshold,omitempty"` - ConsolidateSimilarityThreshold float32 `json:"consolidate_similarity_threshold,omitempty"` - ProactiveReturnAfterBreak *bool `json:"proactive_return_after_break,omitempty"` - StyleMirroringEnabled *bool `json:"style_mirroring_enabled,omitempty"` - AnaphoraResolutionEnabled *bool `json:"anaphora_resolution_enabled,omitempty"` - FollowUpAnticipationEnabled *bool `json:"follow_up_anticipation_enabled,omitempty"` - LLM *LLMConfig `json:"llm,omitempty"` - Embedding *embedding.Config `json:"embedding,omitempty"` + Enabled *bool `json:"enabled,omitempty"` + MaxSizeMB int `json:"max_size_mb,omitempty"` + SemanticSearchTopK int `json:"semantic_search_top_k,omitempty"` + SemanticSearchOverfetch int `json:"semantic_search_overfetch,omitempty"` + SemanticSearchMinScore float32 `json:"semantic_search_min_score,omitempty"` + SemanticSearchRerank *bool `json:"semantic_search_rerank,omitempty"` + AtomMaxChars int `json:"atom_max_chars,omitempty"` + MemoryBudgetChars int `json:"memory_budget_chars,omitempty"` + DecayHalfLifeDays int `json:"decay_half_life_days,omitempty"` + QuarantineTTLDays int `json:"quarantine_ttl_days,omitempty"` + EvictionPolicy string `json:"eviction_policy,omitempty"` + PredictiveIntents int `json:"predictive_intents,omitempty"` + AutoExtractPerTurn *bool `json:"auto_extract_per_turn,omitempty"` + InferUserState *bool `json:"infer_user_state,omitempty"` + UserStateTurnInterval int `json:"user_state_turn_interval,omitempty"` + UserStateMaxPending int `json:"user_state_max_pending,omitempty"` + AssociationsEnabled *bool `json:"associations_enabled,omitempty"` + AssociationSemanticTopK int `json:"association_semantic_top_k,omitempty"` + SemanticDedupThreshold *float32 `json:"semantic_dedup_threshold,omitempty"` + ConsolidateSimilarityThreshold float32 `json:"consolidate_similarity_threshold,omitempty"` + ProactiveReturnAfterBreak *bool `json:"proactive_return_after_break,omitempty"` + StyleMirroringEnabled *bool `json:"style_mirroring_enabled,omitempty"` + AnaphoraResolutionEnabled *bool `json:"anaphora_resolution_enabled,omitempty"` + FollowUpAnticipationEnabled *bool `json:"follow_up_anticipation_enabled,omitempty"` + FollowUpSuggestionsEnabled *bool `json:"follow_up_suggestions_enabled,omitempty"` + FollowUpSuggestionMinConfidence float32 `json:"follow_up_suggestion_min_confidence,omitempty"` + ProactiveNudgesEnabled *bool `json:"proactive_nudges_enabled,omitempty"` + NudgeMaxPerDay int `json:"nudge_max_per_day,omitempty"` + NudgeCooldownHours int `json:"nudge_cooldown_hours,omitempty"` + NudgeStaleGoalDays int `json:"nudge_stale_goal_days,omitempty"` + LLM *LLMConfig `json:"llm,omitempty"` + Embedding *embedding.Config `json:"embedding,omitempty"` } // LLMConfig selects a dedicated LLM for Extended Memory extraction and @@ -66,30 +72,36 @@ func floatPtr(f float32) *float32 { return &f } // Extended Memory is opt-in: Enabled defaults to false. func DefaultConfig() Config { return Config{ - Enabled: boolPtr(false), - MaxSizeMB: 100, - SemanticSearchTopK: 10, - SemanticSearchOverfetch: 4, - SemanticSearchMinScore: 0.55, - SemanticSearchRerank: boolPtr(true), - AtomMaxChars: 300, - MemoryBudgetChars: 2000, - DecayHalfLifeDays: 30, - QuarantineTTLDays: 7, - EvictionPolicy: "retention_decay", - PredictiveIntents: 3, - AutoExtractPerTurn: boolPtr(true), - InferUserState: boolPtr(true), - UserStateTurnInterval: 5, - UserStateMaxPending: 20, - AssociationsEnabled: boolPtr(true), - AssociationSemanticTopK: 3, - SemanticDedupThreshold: floatPtr(0.92), - ConsolidateSimilarityThreshold: 0.9, - ProactiveReturnAfterBreak: boolPtr(true), - StyleMirroringEnabled: boolPtr(true), - AnaphoraResolutionEnabled: boolPtr(true), - FollowUpAnticipationEnabled: boolPtr(true), + Enabled: boolPtr(false), + MaxSizeMB: 100, + SemanticSearchTopK: 10, + SemanticSearchOverfetch: 4, + SemanticSearchMinScore: 0.55, + SemanticSearchRerank: boolPtr(true), + AtomMaxChars: 300, + MemoryBudgetChars: 2000, + DecayHalfLifeDays: 30, + QuarantineTTLDays: 7, + EvictionPolicy: "retention_decay", + PredictiveIntents: 3, + AutoExtractPerTurn: boolPtr(true), + InferUserState: boolPtr(true), + UserStateTurnInterval: 5, + UserStateMaxPending: 20, + AssociationsEnabled: boolPtr(true), + AssociationSemanticTopK: 3, + SemanticDedupThreshold: floatPtr(0.92), + ConsolidateSimilarityThreshold: 0.9, + ProactiveReturnAfterBreak: boolPtr(true), + StyleMirroringEnabled: boolPtr(true), + AnaphoraResolutionEnabled: boolPtr(true), + FollowUpAnticipationEnabled: boolPtr(true), + FollowUpSuggestionsEnabled: boolPtr(true), + FollowUpSuggestionMinConfidence: 0.6, + ProactiveNudgesEnabled: boolPtr(false), // opt-in: proactive features never fire uninvited + NudgeMaxPerDay: 1, + NudgeCooldownHours: 24, + NudgeStaleGoalDays: 7, } } @@ -168,6 +180,24 @@ func Resolve(cfg Config) Config { if cfg.FollowUpAnticipationEnabled != nil { def.FollowUpAnticipationEnabled = cfg.FollowUpAnticipationEnabled } + if cfg.FollowUpSuggestionsEnabled != nil { + def.FollowUpSuggestionsEnabled = cfg.FollowUpSuggestionsEnabled + } + if cfg.FollowUpSuggestionMinConfidence > 0 && cfg.FollowUpSuggestionMinConfidence <= 1 { + def.FollowUpSuggestionMinConfidence = cfg.FollowUpSuggestionMinConfidence + } + if cfg.ProactiveNudgesEnabled != nil { + def.ProactiveNudgesEnabled = cfg.ProactiveNudgesEnabled + } + if cfg.NudgeMaxPerDay > 0 { + def.NudgeMaxPerDay = cfg.NudgeMaxPerDay + } + if cfg.NudgeCooldownHours > 0 { + def.NudgeCooldownHours = cfg.NudgeCooldownHours + } + if cfg.NudgeStaleGoalDays > 0 { + def.NudgeStaleGoalDays = cfg.NudgeStaleGoalDays + } if cfg.LLM != nil { def.LLM = cfg.LLM } diff --git a/internal/memory/extended/extended_memory.go b/internal/memory/extended/extended_memory.go index c1f9946..9480c20 100644 --- a/internal/memory/extended/extended_memory.go +++ b/internal/memory/extended/extended_memory.go @@ -49,6 +49,15 @@ type ExtendedMemory struct { recentUserMessages []string recentMu sync.Mutex + // lastFollowUps holds the high-confidence predicted intents captured + // during the most recent recall, surfaced as follow-up suggestions. + followUpsMu sync.Mutex + lastFollowUps []PredictedIntent + + // nudgeMu serializes TakeNudges so concurrent takers cannot both spend + // the same daily budget. + nudgeMu sync.Mutex + inferenceMu sync.Mutex closed bool inferRunning bool @@ -84,6 +93,7 @@ func New(dir string, llm LLMClient, cfg Config) *ExtendedMemory { llm: llm, } em.recall.SetPredictor(em.predictor) + em.recall.SetFollowUpSink(em.setLastFollowUps) em.recall.stats = &em.stats _ = em.userModel.Load() em.quarantine.SetTTLDays(cfg.QuarantineTTLDays) @@ -137,6 +147,28 @@ func (em *ExtendedMemory) SetSessionContext(sessionID, project string) { em.project = project } +// setLastFollowUps replaces the captured follow-up suggestions. It is the +// sink wired into Recall so every recall refreshes the list; a nil slice +// clears it. +func (em *ExtendedMemory) setLastFollowUps(intents []PredictedIntent) { + em.followUpsMu.Lock() + defer em.followUpsMu.Unlock() + em.lastFollowUps = intents +} + +// LastFollowUps returns a copy of the predicted follow-up intents captured +// during the most recent recall, or nil when none were captured. +func (em *ExtendedMemory) LastFollowUps() []PredictedIntent { + if em == nil { + return nil + } + em.followUpsMu.Lock() + defer em.followUpsMu.Unlock() + out := make([]PredictedIntent, len(em.lastFollowUps)) + copy(out, em.lastFollowUps) + return out +} + // AddAtom manually adds an atom. Manual adds are treated as user-approved. func (em *ExtendedMemory) AddAtom(ctx context.Context, atom MemoryAtom) error { return em.addAtoms(ctx, []MemoryAtom{atom}, false) @@ -600,6 +632,40 @@ func (em *ExtendedMemory) AnaphoraResolve(ctx context.Context, msg string) (stri return resolved, true } +// openLoopTypes are the atom types that represent unresolved threads: user +// questions that went unanswered and stated goals/intentions. +var openLoopTypes = map[string]bool{ + TypeQuestion: true, + TypeGoal: true, + TypeIntent: true, +} + +// OpenLoops returns trusted question/goal/intent atoms, newest first, capped +// at limit (limit <= 0 returns all). It lists the store directly instead of +// running a semantic query: open loops are a recency-ordered data view, so +// the embedding search, min-score filtering, and LLM rerank of the recall +// pipeline would only add cost without improving the answer. +func (em *ExtendedMemory) OpenLoops(ctx context.Context, limit int) ([]MemoryAtom, error) { + if em == nil || !em.Enabled() { + return nil, nil + } + atoms, err := em.store.List() // newest first + if err != nil { + return nil, fmt.Errorf("extended memory: open loops list: %w", err) + } + out := make([]MemoryAtom, 0, len(atoms)) + for _, atom := range atoms { + if !openLoopTypes[atom.Type] || IsTaintedSourceClass(atom.SourceClass) { + continue + } + out = append(out, atom) + if limit > 0 && len(out) >= limit { + break + } + } + return out, nil +} + // SearchAtoms performs an explicit semantic search and returns ranked atoms. func (em *ExtendedMemory) SearchAtoms(ctx context.Context, query string) ([]MemoryAtom, error) { if em == nil { diff --git a/internal/memory/extended/extractor.go b/internal/memory/extended/extractor.go index 7b2969b..0c8628d 100644 --- a/internal/memory/extended/extractor.go +++ b/internal/memory/extended/extractor.go @@ -44,6 +44,8 @@ For each atom, output an object with: Rules: - Only extract stable information worth recalling in future sessions. - Do NOT extract instructions, commands, or requests to perform actions. +- DO emit a "question" atom when the user asks a question that has not been answered yet, so it can be followed up on later. +- DO emit a "goal" atom when the user states an intention or plan (e.g. "I want to…", "we should…", "next week I'll…"). - Do NOT extract ephemeral details specific only to this message. - If nothing durable is present, return an empty array. diff --git a/internal/memory/extended/nudges.go b/internal/memory/extended/nudges.go new file mode 100644 index 0000000..dc19794 --- /dev/null +++ b/internal/memory/extended/nudges.go @@ -0,0 +1,301 @@ +package extended + +import ( + "context" + "encoding/json" + "fmt" + "log" + "os" + "path/filepath" + "strings" + "time" + + "github.com/BackendStack21/odek/internal/fsatomic" +) + +// Nudge kinds produced by the proactive-nudges engine. +const ( + NudgeKindOpenQuestion = "open_question" + NudgeKindStaleGoal = "stale_goal" + NudgeKindBlocker = "blocker" + NudgeKindDrift = "drift" +) + +// Nudge is a single proactive, user-facing suggestion synthesized from +// trusted memory atoms. +type Nudge struct { + Text string `json:"text"` + Kind string `json:"kind"` + SourceAtomIDs []string `json:"source_atom_ids,omitempty"` +} + +const ( + // nudgesFileName is the anti-annoyance state file inside the extended dir. + nudgesFileName = "nudges.json" + // defaultProactiveNudges is used when the caller passes maxN <= 0. + defaultProactiveNudges = 2 + // nudgeCandidateLimit bounds how many open loops feed the synthesis call. + nudgeCandidateLimit = 10 + // maxNudgeTextChars caps a single nudge's text so a runaway LLM response + // cannot bloat the caller's prompt. + maxNudgeTextChars = 300 +) + +// validNudgeKinds is the defensive whitelist for parsed LLM output. +var validNudgeKinds = map[string]bool{ + NudgeKindOpenQuestion: true, + NudgeKindStaleGoal: true, + NudgeKindBlocker: true, + NudgeKindDrift: true, +} + +// nudgeState is the persisted anti-annoyance ledger. +type nudgeState struct { + LastFiredByKind map[string]time.Time `json:"last_fired_by_kind,omitempty"` + Day string `json:"day,omitempty"` + SentToday int `json:"sent_today,omitempty"` +} + +// nudgePrompt asks the memory LLM to synthesize user-facing nudges in one +// call. Episode themes are deliberately not an input: episodes live in the +// parent memory package, and importing it here would create a cycle. +const nudgePrompt = `You are a proactive-assistant nudge generator. Based on the user's open loops (unanswered questions, stated goals and intentions), stale goals, and current focus, suggest up to %d short, helpful nudges. + +Open loops (JSON, newest first): +%s + +Stale goals (no activity in %d+ days, JSON): +%s + +Current focus (JSON): +%s + +Each nudge is an object with: +- "text": one concise, friendly sentence addressed to the user. +- "kind": one of "open_question", "stale_goal", "blocker", "drift". +- "source_atom_ids": the "id" values of the atoms this nudge is based on. + +Rules: +- Use "blocker" when the current focus names a blocker, "drift" when current work diverges from a stated goal, "stale_goal" for goals with no recent activity, "open_question" for unanswered questions. +- Only nudge about things genuinely present in the input; never invent tasks. +- If nothing is worth nudging about, return []. + +Return ONLY a JSON array of at most %d nudges.` + +// ProactiveNudges computes up to maxN nudges as a preview: it performs no +// anti-annoyance checks and records nothing. All failures degrade to an +// empty result with a nil error — proactive features must never break the +// caller. +func (em *ExtendedMemory) ProactiveNudges(ctx context.Context, maxN int) ([]Nudge, error) { + return em.computeNudges(ctx, maxN) +} + +// TakeNudges computes up to maxN nudges and delivers the ones allowed by the +// anti-annoyance caps: the proactive_nudges_enabled master switch (opt-in, +// default off), nudge_max_per_day, and the per-kind nudge_cooldown_hours. +// Delivered nudges are recorded in nudges.json. All failures degrade to an +// empty result with a nil error. +func (em *ExtendedMemory) TakeNudges(ctx context.Context, maxN int) ([]Nudge, error) { + if em == nil || !em.Enabled() { + return nil, nil + } + if em.cfg.ProactiveNudgesEnabled == nil || !*em.cfg.ProactiveNudgesEnabled { + return nil, nil + } + + em.nudgeMu.Lock() + defer em.nudgeMu.Unlock() + + now := time.Now().UTC() + state := em.loadNudgeState() + if state.Day != now.Format("2006-01-02") { + // Day rollover: the daily budget resets, per-kind cooldowns persist. + state.Day = now.Format("2006-01-02") + state.SentToday = 0 + } + maxPerDay := em.cfg.NudgeMaxPerDay + if maxPerDay <= 0 { + maxPerDay = DefaultConfig().NudgeMaxPerDay + } + remaining := maxPerDay - state.SentToday + if remaining <= 0 { + return nil, nil + } + + candidates, err := em.computeNudges(ctx, maxN) + if err != nil || len(candidates) == 0 { + return nil, nil + } + + cooldown := time.Duration(em.cfg.NudgeCooldownHours) * time.Hour + if cooldown <= 0 { + cooldown = time.Duration(DefaultConfig().NudgeCooldownHours) * time.Hour + } + delivered := make([]Nudge, 0, remaining) + for _, n := range candidates { + if len(delivered) >= remaining { + break + } + if last, ok := state.LastFiredByKind[n.Kind]; ok && now.Sub(last) < cooldown { + continue + } + delivered = append(delivered, n) + } + if len(delivered) == 0 { + return nil, nil + } + + if state.LastFiredByKind == nil { + state.LastFiredByKind = make(map[string]time.Time) + } + for _, n := range delivered { + state.LastFiredByKind[n.Kind] = now + } + state.SentToday += len(delivered) + if err := em.saveNudgeState(state); err != nil { + log.Printf("extended memory: nudge state save failed: %v", err) + } + return delivered, nil +} + +// computeNudges gathers open loops, stale goals, and the current focus, then +// synthesizes nudges with a single LLM call. It returns nil on any failure. +func (em *ExtendedMemory) computeNudges(ctx context.Context, maxN int) ([]Nudge, error) { + if em == nil || !em.Enabled() || em.llm == nil { + return nil, nil + } + if maxN <= 0 { + maxN = defaultProactiveNudges + } + + openLoops, err := em.OpenLoops(ctx, nudgeCandidateLimit) + if err != nil { + log.Printf("extended memory: nudge open-loops failed: %v", err) + return nil, nil + } + stale := em.staleGoals() + var focus FocusState + if em.userModel != nil { + focus = em.userModel.State().CurrentFocus + } + if len(openLoops) == 0 && len(stale) == 0 && focus == (FocusState{}) { + return nil, nil + } + + openJSON, _ := json.Marshal(openLoops) + staleJSON, _ := json.Marshal(stale) + focusJSON, _ := json.Marshal(focus) + prompt := fmt.Sprintf(nudgePrompt, maxN, openJSON, em.staleGoalDays(), staleJSON, focusJSON, maxN) + resp, err := em.llm.SimpleCall(ctx, + "You are a proactive-assistant nudge generator. Return only a JSON array of nudges.", + prompt, + ) + if err != nil { + log.Printf("extended memory: nudge synthesis LLM failed: %v", err) + return nil, nil + } + return parseNudges(resp, maxN), nil +} + +// parseNudges defensively parses the LLM nudge response: unknown kinds, +// empty texts, and malformed JSON are dropped, and the result is capped at +// maxN with each text capped at maxNudgeTextChars. +func parseNudges(resp string, maxN int) []Nudge { + resp = strings.TrimSpace(resp) + if resp == "" || resp == "[]" { + return nil + } + jsonResp, ok := extractJSON(resp) + if !ok { + log.Printf("extended memory: nudge parse failed: no JSON in response") + return nil + } + var raw []struct { + Text string `json:"text"` + Kind string `json:"kind"` + SourceAtomIDs []string `json:"source_atom_ids"` + } + if err := json.Unmarshal([]byte(jsonResp), &raw); err != nil { + log.Printf("extended memory: nudge parse failed: %v", err) + return nil + } + out := make([]Nudge, 0, len(raw)) + for _, r := range raw { + text := strings.TrimSpace(r.Text) + if text == "" || !validNudgeKinds[r.Kind] { + continue + } + if len(text) > maxNudgeTextChars { + text = text[:maxNudgeTextChars] + } + out = append(out, Nudge{Text: text, Kind: r.Kind, SourceAtomIDs: r.SourceAtomIDs}) + if len(out) >= maxN { + break + } + } + return out +} + +// staleGoals returns trusted goal/intent atoms whose last activity +// (CreatedAt, refreshed when a goal is re-stated) is older than +// nudge_stale_goal_days. +func (em *ExtendedMemory) staleGoals() []MemoryAtom { + cutoff := time.Now().UTC().Add(-time.Duration(em.staleGoalDays()) * 24 * time.Hour) + atoms, err := em.store.List() + if err != nil { + log.Printf("extended memory: nudge stale-goal list failed: %v", err) + return nil + } + out := make([]MemoryAtom, 0, nudgeCandidateLimit) + for _, atom := range atoms { + if atom.Type != TypeGoal && atom.Type != TypeIntent { + continue + } + if IsTaintedSourceClass(atom.SourceClass) || atom.CreatedAt.After(cutoff) { + continue + } + out = append(out, atom) + if len(out) >= nudgeCandidateLimit { + break + } + } + return out +} + +// staleGoalDays returns the configured stale-goal threshold in days. +func (em *ExtendedMemory) staleGoalDays() int { + if em.cfg.NudgeStaleGoalDays > 0 { + return em.cfg.NudgeStaleGoalDays + } + return DefaultConfig().NudgeStaleGoalDays +} + +// loadNudgeState reads nudges.json. Missing or corrupt files yield a fresh +// state: the anti-annoyance ledger must never wedge nudge delivery. +func (em *ExtendedMemory) loadNudgeState() nudgeState { + data, err := os.ReadFile(filepath.Join(em.dir, nudgesFileName)) + if err != nil { + return nudgeState{} + } + var state nudgeState + if err := json.Unmarshal(data, &state); err != nil { + log.Printf("extended memory: nudge state parse failed: %v", err) + return nudgeState{} + } + return state +} + +// saveNudgeState writes nudges.json atomically with restricted permissions. +func (em *ExtendedMemory) saveNudgeState(state nudgeState) error { + if err := os.MkdirAll(em.dir, 0700); err != nil { + return fmt.Errorf("extended memory: nudge state mkdir: %w", err) + } + data, err := json.MarshalIndent(state, "", " ") + if err != nil { + return fmt.Errorf("extended memory: nudge state marshal: %w", err) + } + if err := fsatomic.WriteFile(filepath.Join(em.dir, nudgesFileName), data, 0600); err != nil { + return fmt.Errorf("extended memory: nudge state write: %w", err) + } + return nil +} diff --git a/internal/memory/extended/proactive_test.go b/internal/memory/extended/proactive_test.go new file mode 100644 index 0000000..c82af3b --- /dev/null +++ b/internal/memory/extended/proactive_test.go @@ -0,0 +1,516 @@ +package extended + +import ( + "context" + "encoding/json" + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/BackendStack21/odek/internal/embedding" +) + +// errLLM always fails, simulating an unreachable memory backend. +type errLLM struct{} + +func (errLLM) SimpleCall(context.Context, string, string) (string, error) { + return "", errors.New("llm unavailable") +} + +// newProactiveEM builds an enabled ExtendedMemory with mock embedders. +func newProactiveEM(t *testing.T, llm LLMClient, mutate func(*Config)) *ExtendedMemory { + t.Helper() + dir := t.TempDir() + cfg := DefaultConfig() + cfg.Enabled = boolPtr(true) + cfg.SemanticSearchMinScore = 0.01 + cfg.SemanticSearchRerank = boolPtr(false) + // The mock embedder can rate distinct short texts as near-duplicates; + // disable the semantic dedup tier so seeded atoms stay separate. + cfg.SemanticDedupThreshold = floatPtr(0) + if mutate != nil { + mutate(&cfg) + } + em := New(dir, llm, cfg) + em.index.newEmb = func() embedding.TextEmbedder { return newMockEmbedder(vectorDim) } + em.index.emb = newMockEmbedder(vectorDim) + t.Cleanup(func() { em.Close() }) + return em +} + +// seedAtom stores a trusted atom with a fixed ID and age. +func seedAtom(t *testing.T, em *ExtendedMemory, id, text, typ string, age time.Duration) { + t.Helper() + atom := MemoryAtom{ + ID: id, + Text: text, + SourceClass: SourceUserSaid, + Type: typ, + CreatedAt: time.Now().UTC().Add(-age), + Confidence: 0.9, + } + if err := em.AddAtom(context.Background(), atom); err != nil { + t.Fatalf("AddAtom failed: %v", err) + } +} + +// writeNudgeState seeds the anti-annoyance ledger on disk. +func writeNudgeState(t *testing.T, em *ExtendedMemory, state nudgeState) { + t.Helper() + data, err := json.Marshal(state) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(em.dir, nudgesFileName), data, 0600); err != nil { + t.Fatal(err) + } +} + +// --- Item A: follow-up intent capture --- + +func TestLastFollowUpsCapturedWithThresholdAndCap(t *testing.T) { + predictLLM := newMockLLM(`[ + {"text":"i1","confidence":0.9}, + {"text":"i2","confidence":0.8}, + {"text":"i3","confidence":0.7}, + {"text":"i4","confidence":0.61}, + {"text":"i5","confidence":0.55} + ]`) + em := newProactiveEM(t, newMockLLM(), func(c *Config) { c.PredictiveIntents = 5 }) + em.recall.predictor = NewPredictor(predictLLM, em.cfg) + + if _, err := em.recall.queryAtomsWithPrediction(context.Background(), "anything", nil, UserState{}); err != nil { + t.Fatalf("queryAtomsWithPrediction failed: %v", err) + } + got := em.LastFollowUps() + // i4 (0.61 >= 0.6) survives the threshold but is cut by the cap of 3; + // i5 (0.55) is below follow_up_suggestion_min_confidence. + if len(got) != 3 { + t.Fatalf("expected 3 follow-ups (cap), got %d: %+v", len(got), got) + } + for i, want := range []string{"i1", "i2", "i3"} { + if got[i].Text != want { + t.Errorf("follow-up %d = %q, want %q", i, got[i].Text, want) + } + } +} + +func TestLastFollowUpsReplacedOnNextRecall(t *testing.T) { + predictLLM := newMockLLM( + `[{"text":"first","confidence":0.9}]`, + `[{"text":"second","confidence":0.9}]`, + ) + em := newProactiveEM(t, newMockLLM(), nil) + em.recall.predictor = NewPredictor(predictLLM, em.cfg) + + if _, err := em.recall.queryAtomsWithPrediction(context.Background(), "q1", nil, UserState{}); err != nil { + t.Fatal(err) + } + if got := em.LastFollowUps(); len(got) != 1 || got[0].Text != "first" { + t.Fatalf("expected [first], got %+v", got) + } + if _, err := em.recall.queryAtomsWithPrediction(context.Background(), "q2", nil, UserState{}); err != nil { + t.Fatal(err) + } + got := em.LastFollowUps() + if len(got) != 1 || got[0].Text != "second" { + t.Fatalf("expected [second] after second recall, got %+v", got) + } +} + +func TestLastFollowUpsDisabledStoresNothing(t *testing.T) { + predictLLM := newMockLLM(`[{"text":"i1","confidence":0.9}]`) + em := newProactiveEM(t, newMockLLM(), func(c *Config) { + c.FollowUpSuggestionsEnabled = boolPtr(false) + }) + em.recall.predictor = NewPredictor(predictLLM, em.cfg) + + if _, err := em.recall.queryAtomsWithPrediction(context.Background(), "q", nil, UserState{}); err != nil { + t.Fatal(err) + } + if got := em.LastFollowUps(); len(got) != 0 { + t.Errorf("expected no follow-ups when disabled, got %+v", got) + } +} + +func TestLastFollowUpsReturnsCopy(t *testing.T) { + em := newProactiveEM(t, newMockLLM(), nil) + em.setLastFollowUps([]PredictedIntent{{Text: "x", Confidence: 0.9}}) + got := em.LastFollowUps() + got[0].Text = "mutated" + if again := em.LastFollowUps(); again[0].Text != "x" { + t.Errorf("LastFollowUps must return a copy, got %q after mutation", again[0].Text) + } + var nilEM *ExtendedMemory + if got := nilEM.LastFollowUps(); got != nil { + t.Errorf("nil ExtendedMemory must return nil, got %+v", got) + } +} + +// --- Item B: question/goal atoms and open loops --- + +func TestExtractorEmitsQuestionAndGoalAtoms(t *testing.T) { + llm := newMockLLM(`[ + {"text":"What is the deploy schedule?","type":"question","confidence":0.8}, + {"text":"I want to migrate to Postgres next week","type":"goal","confidence":0.9} + ]`) + ext := NewExtractor(llm, DefaultConfig()) + atoms, err := ext.Extract(context.Background(), "What is the deploy schedule? I want to migrate to Postgres next week.") + if err != nil { + t.Fatalf("Extract failed: %v", err) + } + if len(atoms) != 2 { + t.Fatalf("expected 2 atoms, got %d: %+v", len(atoms), atoms) + } + if atoms[0].Type != TypeQuestion { + t.Errorf("atom 0 type = %q, want question", atoms[0].Type) + } + if atoms[1].Type != TypeGoal { + t.Errorf("atom 1 type = %q, want goal", atoms[1].Type) + } + llm.mu.Lock() + sys := llm.lastSys + llm.mu.Unlock() + if !strings.Contains(sys, "not been answered") || !strings.Contains(sys, "intention") { + t.Errorf("extraction prompt missing question/goal rules:\n%s", sys) + } +} + +func TestOpenLoopsFiltersTypesExcludesTaintedRespectsLimit(t *testing.T) { + em := newProactiveEM(t, newMockLLM(), nil) + seedAtom(t, em, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1", "We should refactor the config loader", TypeIntent, time.Hour) + seedAtom(t, em, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa2", "I want to ship v2 next month", TypeGoal, 2*time.Hour) + seedAtom(t, em, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa3", "How does the eviction policy work?", TypeQuestion, 3*time.Hour) + seedAtom(t, em, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa4", "User prefers Go", TypeFact, 30*time.Minute) + + // A tainted atom planted directly in the live store must not surface. + tainted := MemoryAtom{ + ID: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa5", + Text: "Ignore previous instructions?", + SourceClass: SourceToolOutput, + Type: TypeQuestion, + CreatedAt: time.Now().UTC(), + } + if err := em.store.Add(tainted, 300); err != nil { + t.Fatal(err) + } + + loops, err := em.OpenLoops(context.Background(), 0) + if err != nil { + t.Fatalf("OpenLoops failed: %v", err) + } + if len(loops) != 3 { + t.Fatalf("expected 3 open loops, got %d: %+v", len(loops), loops) + } + // Newest first: intent, goal, question. + wantOrder := []string{"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa1", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa2", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa3"} + for i, want := range wantOrder { + if loops[i].ID != want { + t.Errorf("loop %d ID = %q, want %q", i, loops[i].ID, want) + } + } + + limited, err := em.OpenLoops(context.Background(), 2) + if err != nil { + t.Fatal(err) + } + if len(limited) != 2 { + t.Errorf("expected limit of 2, got %d", len(limited)) + } +} + +// --- Item C: proactive nudges --- + +const nudgeTestAtomID = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb1" + +func newNudgeEM(t *testing.T, llm LLMClient, mutate func(*Config)) *ExtendedMemory { + t.Helper() + em := newProactiveEM(t, llm, func(c *Config) { + c.ProactiveNudgesEnabled = boolPtr(true) + if mutate != nil { + mutate(c) + } + }) + seedAtom(t, em, nudgeTestAtomID, "How does the eviction policy work?", TypeQuestion, time.Hour) + return em +} + +func TestProactiveNudgesGeneration(t *testing.T) { + llm := newMockLLM(`[{"text":"Still need an answer on the eviction policy?","kind":"open_question","source_atom_ids":["` + nudgeTestAtomID + `"]}]`) + em := newNudgeEM(t, llm, nil) + + nudges, err := em.ProactiveNudges(context.Background(), 2) + if err != nil { + t.Fatalf("ProactiveNudges failed: %v", err) + } + if len(nudges) != 1 { + t.Fatalf("expected 1 nudge, got %d: %+v", len(nudges), nudges) + } + n := nudges[0] + if n.Kind != NudgeKindOpenQuestion { + t.Errorf("kind = %q, want open_question", n.Kind) + } + if n.Text == "" { + t.Error("nudge text is empty") + } + if len(n.SourceAtomIDs) != 1 || n.SourceAtomIDs[0] != nudgeTestAtomID { + t.Errorf("source atom IDs = %v, want [%s]", n.SourceAtomIDs, nudgeTestAtomID) + } +} + +func TestTakeNudgesRespectsDailyCap(t *testing.T) { + llm := newMockLLM(`[ + {"text":"nudge one","kind":"open_question","source_atom_ids":["` + nudgeTestAtomID + `"]}, + {"text":"nudge two","kind":"stale_goal","source_atom_ids":["` + nudgeTestAtomID + `"]} + ]`) + em := newNudgeEM(t, llm, func(c *Config) { c.NudgeMaxPerDay = 1 }) + + nudges, err := em.TakeNudges(context.Background(), 2) + if err != nil { + t.Fatal(err) + } + if len(nudges) != 1 { + t.Fatalf("expected 1 nudge under daily cap, got %d", len(nudges)) + } + // The daily budget is spent: a second take returns nothing and must not + // spend another LLM call. + again, err := em.TakeNudges(context.Background(), 2) + if err != nil { + t.Fatal(err) + } + if len(again) != 0 { + t.Errorf("expected no nudges after daily cap, got %+v", again) + } + if llm.calls() != 1 { + t.Errorf("expected 1 LLM call total, got %d", llm.calls()) + } +} + +func TestTakeNudgesPerKindCooldown(t *testing.T) { + llm := newMockLLM(`[ + {"text":"cooling down","kind":"open_question","source_atom_ids":["` + nudgeTestAtomID + `"]}, + {"text":"fresh kind","kind":"stale_goal","source_atom_ids":["` + nudgeTestAtomID + `"]} + ]`) + em := newNudgeEM(t, llm, func(c *Config) { + c.NudgeMaxPerDay = 10 + c.NudgeCooldownHours = 24 + }) + now := time.Now().UTC() + writeNudgeState(t, em, nudgeState{ + LastFiredByKind: map[string]time.Time{NudgeKindOpenQuestion: now.Add(-time.Hour)}, + Day: now.Format("2006-01-02"), + SentToday: 0, + }) + + nudges, err := em.TakeNudges(context.Background(), 5) + if err != nil { + t.Fatal(err) + } + if len(nudges) != 1 || nudges[0].Kind != NudgeKindStaleGoal { + t.Fatalf("expected only the stale_goal nudge (open_question in cooldown), got %+v", nudges) + } +} + +func TestTakeNudgesDayRollover(t *testing.T) { + llm := newMockLLM(`[{"text":"back today","kind":"open_question","source_atom_ids":["` + nudgeTestAtomID + `"]}]`) + em := newNudgeEM(t, llm, func(c *Config) { c.NudgeMaxPerDay = 1 }) + old := time.Now().UTC().Add(-48 * time.Hour) + writeNudgeState(t, em, nudgeState{ + LastFiredByKind: map[string]time.Time{NudgeKindOpenQuestion: old}, + Day: old.Format("2006-01-02"), + SentToday: 9, // over the cap, but for a past day + }) + + nudges, err := em.TakeNudges(context.Background(), 2) + if err != nil { + t.Fatal(err) + } + if len(nudges) != 1 { + t.Fatalf("expected 1 nudge after day rollover, got %+v", nudges) + } + state := em.loadNudgeState() + if state.Day != time.Now().UTC().Format("2006-01-02") { + t.Errorf("day = %q, want today", state.Day) + } + if state.SentToday != 1 { + t.Errorf("sent_today = %d, want 1 (budget reset on rollover)", state.SentToday) + } +} + +func TestTakeNudgesDisabledByDefault(t *testing.T) { + llm := newMockLLM(`[{"text":"x","kind":"open_question","source_atom_ids":[]}]`) + em := newProactiveEM(t, llm, nil) // proactive_nudges_enabled defaults to false + seedAtom(t, em, nudgeTestAtomID, "How does the eviction policy work?", TypeQuestion, time.Hour) + + nudges, err := em.TakeNudges(context.Background(), 2) + if err != nil { + t.Fatal(err) + } + if len(nudges) != 0 { + t.Errorf("expected no nudges when disabled, got %+v", nudges) + } + if llm.calls() != 0 { + t.Errorf("disabled TakeNudges must not call the LLM, got %d calls", llm.calls()) + } +} + +func TestProactiveNudgesPreviewDoesNotConsumeCaps(t *testing.T) { + resp := `[{"text":"preview me","kind":"open_question","source_atom_ids":["` + nudgeTestAtomID + `"]}]` + llm := newMockLLM(resp, resp) + em := newNudgeEM(t, llm, func(c *Config) { c.NudgeMaxPerDay = 1 }) + + preview, err := em.ProactiveNudges(context.Background(), 2) + if err != nil { + t.Fatal(err) + } + if len(preview) != 1 { + t.Fatalf("expected 1 preview nudge, got %+v", preview) + } + if _, err := os.Stat(filepath.Join(em.dir, nudgesFileName)); !os.IsNotExist(err) { + t.Errorf("preview must not write %s", nudgesFileName) + } + // The preview consumed nothing: the take still delivers. + taken, err := em.TakeNudges(context.Background(), 2) + if err != nil { + t.Fatal(err) + } + if len(taken) != 1 { + t.Errorf("expected take to deliver after preview, got %+v", taken) + } +} + +func TestProactiveNudgesFailuresDegradeToEmpty(t *testing.T) { + cases := []struct { + name string + llm LLMClient + }{ + {"llm error", errLLM{}}, + {"unparseable response", newMockLLM("this is not json")}, + {"no llm", nil}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + em := newNudgeEM(t, tc.llm, nil) + for _, call := range []func() ([]Nudge, error){ + func() ([]Nudge, error) { return em.ProactiveNudges(context.Background(), 2) }, + func() ([]Nudge, error) { return em.TakeNudges(context.Background(), 2) }, + } { + nudges, err := call() + if err != nil { + t.Errorf("expected nil error, got %v", err) + } + if len(nudges) != 0 { + t.Errorf("expected empty nudges, got %+v", nudges) + } + } + }) + } +} + +func TestParseNudgesDefensive(t *testing.T) { + out := parseNudges(`[ + {"text":"ok","kind":"drift","source_atom_ids":["a"]}, + {"text":"bad kind","kind":"nonsense"}, + {"text":"","kind":"blocker"}, + {"text":"over cap","kind":"blocker"} + ]`, 2) + if len(out) != 2 { + t.Fatalf("expected 2 nudges (invalid dropped), got %+v", out) + } + if out[0].Kind != NudgeKindDrift || out[1].Kind != NudgeKindBlocker { + t.Errorf("unexpected kinds: %+v", out) + } + long := strings.Repeat("x", maxNudgeTextChars+50) + out = parseNudges(`[{"text":"`+long+`","kind":"blocker"}]`, 2) + if len(out) != 1 || len(out[0].Text) != maxNudgeTextChars { + t.Errorf("expected text capped at %d chars, got %+v", maxNudgeTextChars, out) + } +} + +func TestProactiveConfigDefaultsAndFlooring(t *testing.T) { + def := DefaultConfig() + if def.FollowUpSuggestionsEnabled == nil || !*def.FollowUpSuggestionsEnabled { + t.Error("FollowUpSuggestionsEnabled should default to true") + } + if def.FollowUpSuggestionMinConfidence != 0.6 { + t.Errorf("FollowUpSuggestionMinConfidence = %v, want 0.6", def.FollowUpSuggestionMinConfidence) + } + if def.ProactiveNudgesEnabled == nil || *def.ProactiveNudgesEnabled { + t.Error("ProactiveNudgesEnabled should default to false (opt-in)") + } + if def.NudgeMaxPerDay != 1 { + t.Errorf("NudgeMaxPerDay = %d, want 1", def.NudgeMaxPerDay) + } + if def.NudgeCooldownHours != 24 { + t.Errorf("NudgeCooldownHours = %d, want 24", def.NudgeCooldownHours) + } + if def.NudgeStaleGoalDays != 7 { + t.Errorf("NudgeStaleGoalDays = %d, want 7", def.NudgeStaleGoalDays) + } + + // Invalid values are floored to defaults by Resolve. + res := Resolve(Config{ + FollowUpSuggestionMinConfidence: 1.5, + NudgeMaxPerDay: -2, + NudgeCooldownHours: 0, + NudgeStaleGoalDays: -7, + }) + if res.FollowUpSuggestionMinConfidence != 0.6 { + t.Errorf("invalid min confidence not floored: %v", res.FollowUpSuggestionMinConfidence) + } + if res.NudgeMaxPerDay != 1 || res.NudgeCooldownHours != 24 || res.NudgeStaleGoalDays != 7 { + t.Errorf("invalid nudge caps not floored: %+v", res) + } + + // Valid overrides win. + res = Resolve(Config{ + FollowUpSuggestionsEnabled: boolPtr(false), + FollowUpSuggestionMinConfidence: 0.8, + ProactiveNudgesEnabled: boolPtr(true), + NudgeMaxPerDay: 5, + NudgeCooldownHours: 12, + NudgeStaleGoalDays: 14, + }) + if res.FollowUpSuggestionsEnabled == nil || *res.FollowUpSuggestionsEnabled { + t.Error("FollowUpSuggestionsEnabled override not applied") + } + if res.FollowUpSuggestionMinConfidence != 0.8 { + t.Errorf("FollowUpSuggestionMinConfidence = %v, want 0.8", res.FollowUpSuggestionMinConfidence) + } + if res.ProactiveNudgesEnabled == nil || !*res.ProactiveNudgesEnabled { + t.Error("ProactiveNudgesEnabled override not applied") + } + if res.NudgeMaxPerDay != 5 || res.NudgeCooldownHours != 12 || res.NudgeStaleGoalDays != 14 { + t.Errorf("nudge cap overrides not applied: %+v", res) + } +} + +func TestNudgeStateRoundTrip(t *testing.T) { + em := newProactiveEM(t, newMockLLM(), nil) + now := time.Now().UTC().Truncate(time.Second) + want := nudgeState{ + LastFiredByKind: map[string]time.Time{NudgeKindBlocker: now}, + Day: now.Format("2006-01-02"), + SentToday: 3, + } + if err := em.saveNudgeState(want); err != nil { + t.Fatalf("saveNudgeState failed: %v", err) + } + info, err := os.Stat(filepath.Join(em.dir, nudgesFileName)) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0600 { + t.Errorf("nudges.json mode = %o, want 0600", info.Mode().Perm()) + } + got := em.loadNudgeState() + if got.Day != want.Day || got.SentToday != want.SentToday { + t.Errorf("round trip = %+v, want %+v", got, want) + } + if !got.LastFiredByKind[NudgeKindBlocker].Equal(now) { + t.Errorf("last_fired_by_kind = %v, want %v", got.LastFiredByKind, want.LastFiredByKind) + } +} diff --git a/internal/memory/extended/recall.go b/internal/memory/extended/recall.go index 1369ee4..bad20bc 100644 --- a/internal/memory/extended/recall.go +++ b/internal/memory/extended/recall.go @@ -16,6 +16,10 @@ import ( // intent is skipped entirely. const minPredictedIntentConfidence = 0.3 +// maxFollowUpSuggestions caps how many predicted intents are kept per recall +// as follow-up suggestions for the user-facing surface. +const maxFollowUpSuggestions = 3 + // Recall performs semantic search over the atom store. type Recall struct { store *AtomStore @@ -26,6 +30,11 @@ type Recall struct { guard guard.Guard guardCfg guard.Config stats *recallStats + + // followUpSink receives the high-confidence predicted intents captured + // during recall so ExtendedMemory can surface them as follow-up + // suggestions. Nil disables capture without changing recall behavior. + followUpSink func([]PredictedIntent) } // NewRecall creates a Recall instance. @@ -52,6 +61,13 @@ func (r *Recall) SetPredictor(p *Predictor) { r.predictor = p } +// SetFollowUpSink installs the callback that receives the follow-up +// suggestions captured at recall time (zero extra LLM cost: the intents are +// already generated for predictive recall). +func (r *Recall) SetFollowUpSink(fn func([]PredictedIntent)) { + r.followUpSink = fn +} + // QueryResult carries the atoms and formatted context from a recall query. type QueryResult struct { Atoms []MemoryAtom @@ -112,10 +128,20 @@ func (r *Recall) queryAtomsWithPrediction(ctx context.Context, query string, rec r.trackErr(err) log.Printf("extended memory: predicted-intent generation failed: %v", err) } + capture := r.followUpSink != nil && + r.cfg.FollowUpSuggestionsEnabled != nil && *r.cfg.FollowUpSuggestionsEnabled + minConf := r.cfg.FollowUpSuggestionMinConfidence + if minConf <= 0 || minConf > 1 { + minConf = DefaultConfig().FollowUpSuggestionMinConfidence + } + var followUps []PredictedIntent for _, intent := range intents { if intent.Confidence < minPredictedIntentConfidence { continue } + if capture && intent.Confidence >= minConf && len(followUps) < maxFollowUpSuggestions { + followUps = append(followUps, intent) + } // Predicted intents reuse the composite score but skip the paid // LLM rerank, which is reserved for the literal query. predicted, err := r.queryAtomsScored(ctx, intent.Text, true) @@ -135,6 +161,9 @@ func (r *Recall) queryAtomsWithPrediction(ctx context.Context, query string, rec } } } + if capture { + r.followUpSink(followUps) + } } out := make([]scoredAtomMeta, 0, len(all)) diff --git a/internal/memory/memory.go b/internal/memory/memory.go index e1fca1b..0849bce 100644 --- a/internal/memory/memory.go +++ b/internal/memory/memory.go @@ -527,6 +527,46 @@ func (m *MemoryManager) ListPendingReview() ([]extended.PendingReview, error) { return m.extended.ListPendingReview() } +// FollowUpSuggestions returns the follow-up intent texts captured during the +// most recent Extended Memory recall. It returns nil when Extended Memory is +// disabled or uninitialized, or when no suggestions were captured. +func (m *MemoryManager) FollowUpSuggestions() []string { + if m.extended == nil || !m.extended.Enabled() { + return nil + } + intents := m.extended.LastFollowUps() + if len(intents) == 0 { + return nil + } + out := make([]string, 0, len(intents)) + for _, intent := range intents { + if intent.Text != "" { + out = append(out, intent.Text) + } + } + return out +} + +// PreviewNudges computes proactive nudges without checking or recording the +// anti-annoyance caps. It returns an empty result when Extended Memory is +// disabled or uninitialized. +func (m *MemoryManager) PreviewNudges(ctx context.Context, maxN int) ([]extended.Nudge, error) { + if m.extended == nil { + return nil, nil + } + return m.extended.ProactiveNudges(ctx, maxN) +} + +// TakeNudges computes proactive nudges and delivers the ones allowed by the +// anti-annoyance caps, recording delivery. It returns an empty result when +// Extended Memory is disabled or uninitialized. +func (m *MemoryManager) TakeNudges(ctx context.Context, maxN int) ([]extended.Nudge, error) { + if m.extended == nil { + return nil, nil + } + return m.extended.TakeNudges(ctx, maxN) +} + // notify fires an event on the configured notifier, stamping the UTC timestamp // when the caller left it zero. Safe even before SetNotifier (nil → no-op). func (m *MemoryManager) notify(ev MemoryEvent) { diff --git a/internal/memory/memory_test.go b/internal/memory/memory_test.go index dd988ac..82df061 100644 --- a/internal/memory/memory_test.go +++ b/internal/memory/memory_test.go @@ -128,6 +128,21 @@ func TestMemoryManagerDisabled(t *testing.T) { } } +// TestProactivePassthroughsNilExtended verifies the proactive-engagement +// passthroughs are nil-safe when Extended Memory is not initialized. +func TestProactivePassthroughsNilExtended(t *testing.T) { + mm := NewMemoryManager(t.TempDir(), nil, DefaultMemoryConfig()) + if got := mm.FollowUpSuggestions(); got != nil { + t.Errorf("FollowUpSuggestions = %v, want nil", got) + } + if got, err := mm.PreviewNudges(context.Background(), 2); got != nil || err != nil { + t.Errorf("PreviewNudges = %v, %v, want nil, nil", got, err) + } + if got, err := mm.TakeNudges(context.Background(), 2); got != nil || err != nil { + t.Errorf("TakeNudges = %v, %v, want nil, nil", got, err) + } +} + func TestMemoryManagerSecurityScan(t *testing.T) { dir := t.TempDir() mm := NewMemoryManager(dir, nil, DefaultMemoryConfig()) From 1f4203db6deac8bbd689575c089331fd2952e73f Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso Date: Wed, 22 Jul 2026 15:03:40 +0200 Subject: [PATCH 3/7] feat(cli+telegram): proactive engagement surfaces - run/REPL print a compact '-- You might also want to --' block after answers (engaging/enhance modes only; presentation-only, never part of the response transcript) - ReturnAfterBreak extracted into a shared helper and wired into REPL session resume and Telegram /resume (previously run --continue only) - new 'odek memory extended nudges' preview command (shows pending nudges without consuming the daily cap) - Telegram bot: agent now gets full memory wiring, and after each completed turn an opt-in (proactive_nudges_enabled) background TakeNudges pushes a nudge message, honoring the shared persisted caps --- cmd/odek/main.go | 28 +--- cmd/odek/memory_cmd.go | 32 +++- cmd/odek/memory_cmd_test.go | 117 ++++++++++++++ cmd/odek/proactive.go | 92 +++++++++++ cmd/odek/proactive_test.go | 308 ++++++++++++++++++++++++++++++++++++ cmd/odek/repl.go | 17 ++ cmd/odek/telegram.go | 62 ++++++++ 7 files changed, 634 insertions(+), 22 deletions(-) create mode 100644 cmd/odek/proactive.go create mode 100644 cmd/odek/proactive_test.go diff --git a/cmd/odek/main.go b/cmd/odek/main.go index 311d4d1..28ba241 100644 --- a/cmd/odek/main.go +++ b/cmd/odek/main.go @@ -1523,6 +1523,13 @@ func run(args []string) error { fmt.Println(result) } + // ── Follow-up suggestions: compact block after a successful turn ── + // Presentation-only (engaging/enhance modes) — never appended to the + // response string, so session/memory transcripts stay clean. + if mm := agent.Memory(); mm != nil { + printFollowUpSuggestions(os.Stdout, mm, resolved.InteractionMode) + } + return nil } @@ -2408,26 +2415,7 @@ func continueCmd(args []string) error { // Return-after-break: on session resume, load a concise summary of where // the user left off and the next likely step. - if mm := agent.Memory(); mm != nil { - rbCtx, rbCancel := context.WithTimeout(ctx, 5*time.Second) - if rb := mm.FormatReturnAfterBreak(rbCtx); rb != "" { - insertIdx := -1 - for i := len(messages) - 1; i >= 0; i-- { - if messages[i].Role == "system" { - insertIdx = i - break - } - } - wrapped := wrapUntrusted(rbCtx, "return_after_break", rb) - rbMsg := llm.Message{Role: "system", Content: wrapped} - if insertIdx >= 0 { - messages = append(messages[:insertIdx+1], append([]llm.Message{rbMsg}, messages[insertIdx+1:]...)...) - } else { - messages = append([]llm.Message{rbMsg}, messages...) - } - } - rbCancel() - } + messages = injectReturnAfterBreak(ctx, agent.Memory(), messages) messages = append(messages, llm.Message{Role: "user", Content: task}) diff --git a/cmd/odek/memory_cmd.go b/cmd/odek/memory_cmd.go index 682fb91..3008c67 100644 --- a/cmd/odek/memory_cmd.go +++ b/cmd/odek/memory_cmd.go @@ -78,7 +78,7 @@ func memoryCmd(args []string) error { // extendedMemoryCmd handles `odek memory extended `. func extendedMemoryCmd(dir string, args []string) error { if len(args) == 0 { - fmt.Fprintf(os.Stderr, "Usage: odek memory extended [args]\n") + fmt.Fprintf(os.Stderr, "Usage: odek memory extended [args]\n") return nil } @@ -191,6 +191,34 @@ func extendedMemoryCmd(dir string, args []string) error { fmt.Printf("odek: consolidation complete — %d atom(s) merged into existing or new entries\n", merged) return nil + case "nudges": + // Generating nudges needs an LLM; resolve the operator backend the + // same way the agent does (same pattern as consolidate). + resolved := config.LoadConfig(config.CLIFlags{}) + if resolved.APIKey == "" { + return fmt.Errorf("memory extended nudges requires an LLM backend (no API key resolved)") + } + llmClient := llm.New(resolved.BaseURL, resolved.APIKey, resolved.Model, "", 0, 120*time.Second) + emLLM := extended.New(extDir, llmClient, cfg) + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + nudges, err := emLLM.ProactiveNudges(ctx, 2) + if err != nil { + return err + } + if len(nudges) == 0 { + fmt.Println("No nudges right now.") + return nil + } + fmt.Println("Proactive nudges (preview — does not consume the daily cap):") + for _, n := range nudges { + fmt.Printf("• [%s] %s\n", n.Kind, n.Text) + if len(n.SourceAtomIDs) > 0 { + fmt.Printf(" atoms: %s\n", strings.Join(n.SourceAtomIDs, ", ")) + } + } + return nil + case "pending": pending, err := em.ListPendingReview() if err != nil { @@ -233,6 +261,6 @@ func extendedMemoryCmd(dir string, args []string) error { return nil default: - return fmt.Errorf("unknown extended memory subcommand %q (expected: forget, promote, pin, quarantine, compact, stats, consolidate, pending, confirm, reject)", sub) + return fmt.Errorf("unknown extended memory subcommand %q (expected: forget, promote, pin, quarantine, compact, stats, consolidate, nudges, pending, confirm, reject)", sub) } } diff --git a/cmd/odek/memory_cmd_test.go b/cmd/odek/memory_cmd_test.go index 8d9b4a9..4da57ad 100644 --- a/cmd/odek/memory_cmd_test.go +++ b/cmd/odek/memory_cmd_test.go @@ -1,10 +1,18 @@ package main import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "os" "path/filepath" + "strings" "testing" + "time" "github.com/BackendStack21/odek/internal/memory" + "github.com/BackendStack21/odek/internal/memory/extended" ) // TestMemoryCmd_ListAndPromote exercises the human-gated promote path end to @@ -51,3 +59,112 @@ func TestMemoryCmd_ListEmpty(t *testing.T) { t.Fatalf("memory list on empty home: %v", err) } } + +// ── Extended Memory: nudges preview ───────────────────────────────── + +// unsetAPIKeys clears LLM API key env vars for the duration of a test. +func unsetAPIKeys(t *testing.T) { + t.Helper() + for _, k := range []string{"DEEPSEEK_API_KEY", "OPENAI_API_KEY", "ODEK_API_KEY"} { + orig, ok := os.LookupEnv(k) + os.Unsetenv(k) + if ok { + t.Cleanup(func() { os.Setenv(k, orig) }) + } + } +} + +// TestMemoryCmd_ExtendedNudges_NoBackend: nudges needs an LLM backend. +func TestMemoryCmd_ExtendedNudges_NoBackend(t *testing.T) { + setupTestHome(t) + unsetAPIKeys(t) + if err := memoryCmd([]string{"extended", "nudges"}); err == nil { + t.Fatal("nudges without an API key should error") + } +} + +// TestMemoryCmd_ExtendedNudges_Empty: a fresh store prints the empty message. +func TestMemoryCmd_ExtendedNudges_Empty(t *testing.T) { + home := setupTestHome(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"choices":[{"message":{"content":"[]"}}]}`) + })) + defer srv.Close() + + os.MkdirAll(filepath.Join(home, ".odek"), 0700) + cfgJSON := fmt.Sprintf(`{"base_url": %q, "api_key": "sk-mock", "model": "mock-model"}`, srv.URL) + if err := os.WriteFile(filepath.Join(home, ".odek", "config.json"), []byte(cfgJSON), 0600); err != nil { + t.Fatal(err) + } + // Env outranks config.json and shields the test from env leftovers of + // earlier tests in the package. + t.Setenv("ODEK_BASE_URL", srv.URL) + t.Setenv("DEEPSEEK_API_KEY", "sk-mock") + + out := captureStdout(func() { + if err := memoryCmd([]string{"extended", "nudges"}); err != nil { + t.Errorf("nudges on empty store: %v", err) + } + }) + if !strings.Contains(out, "No nudges right now.") { + t.Errorf("expected empty-nudges message, got %q", out) + } +} + +// TestMemoryCmd_ExtendedNudges_PrintsNudges: with a stale goal in the store +// and an LLM that synthesizes a nudge, the preview prints kind, text, and +// source atom IDs — and notes that the daily cap is not consumed. +func TestMemoryCmd_ExtendedNudges_PrintsNudges(t *testing.T) { + home := setupTestHome(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"choices":[{"message":{"content":"[{\"text\":\"Your goal 'ship v2' went quiet.\",\"kind\":\"stale_goal\",\"source_atom_ids\":[\"atom-1\"]}]"}}]}`) + })) + defer srv.Close() + + os.MkdirAll(filepath.Join(home, ".odek"), 0700) + cfgJSON := fmt.Sprintf(`{"base_url": %q, "api_key": "sk-mock", "model": "mock-model"}`, srv.URL) + if err := os.WriteFile(filepath.Join(home, ".odek", "config.json"), []byte(cfgJSON), 0600); err != nil { + t.Fatal(err) + } + // Env outranks config.json and shields the test from env leftovers of + // earlier tests in the package. + t.Setenv("ODEK_BASE_URL", srv.URL) + t.Setenv("DEEPSEEK_API_KEY", "sk-mock") + + // Seed a stale goal atom (old enough to clear the stale-goal threshold). + extDir := filepath.Join(home, ".odek", "memory", "extended") + extCfg := extended.DefaultConfig() + enabled := true + extCfg.Enabled = &enabled + seeder := extended.New(extDir, nil, extCfg) + if err := seeder.AddAtom(context.Background(), extended.MemoryAtom{ + Text: "Ship v2 of the API", + SourceClass: extended.SourceUserSaid, + Type: extended.TypeGoal, + CreatedAt: time.Now().Add(-30 * 24 * time.Hour), + }); err != nil { + t.Fatalf("seed atom: %v", err) + } + if err := seeder.Close(); err != nil { + t.Fatalf("close seeder: %v", err) + } + + out := captureStdout(func() { + if err := memoryCmd([]string{"extended", "nudges"}); err != nil { + t.Errorf("nudges: %v", err) + } + }) + if !strings.Contains(out, "• [stale_goal] Your goal 'ship v2' went quiet.") { + t.Errorf("expected nudge line, got %q", out) + } + if !strings.Contains(out, "atoms: atom-1") { + t.Errorf("expected source atom IDs, got %q", out) + } + if !strings.Contains(out, "does not consume the daily cap") { + t.Errorf("expected preview note, got %q", out) + } +} diff --git a/cmd/odek/proactive.go b/cmd/odek/proactive.go new file mode 100644 index 0000000..d061f18 --- /dev/null +++ b/cmd/odek/proactive.go @@ -0,0 +1,92 @@ +package main + +import ( + "context" + "fmt" + "io" + "strings" + "time" + + "github.com/BackendStack21/odek/internal/llm" + "github.com/BackendStack21/odek/internal/memory" +) + +// ── Proactive Engagement (presentation layer) ───────────────────────── +// +// Shared presentation helpers for the proactive engagement feature: +// return-after-break injection on session resume, and follow-up +// suggestions printed after a completed turn. Both are presentation-only — +// nothing here is appended to the agent response or persisted into session +// transcripts. + +// injectReturnAfterBreak loads a concise "where you left off" summary from +// Extended Memory and inserts it — wrapped as untrusted content — into the +// message history immediately after the last system message. When there is +// no summary (extended memory disabled, no atoms, LLM failure) the messages +// are returned unchanged. +func injectReturnAfterBreak(ctx context.Context, mm *memory.MemoryManager, messages []llm.Message) []llm.Message { + if mm == nil { + return messages + } + rbCtx, rbCancel := context.WithTimeout(ctx, 5*time.Second) + defer rbCancel() + rb := mm.FormatReturnAfterBreak(rbCtx) + if rb == "" { + return messages + } + insertIdx := -1 + for i := len(messages) - 1; i >= 0; i-- { + if messages[i].Role == "system" { + insertIdx = i + break + } + } + wrapped := wrapUntrusted(rbCtx, "return_after_break", rb) + rbMsg := llm.Message{Role: "system", Content: wrapped} + if insertIdx >= 0 { + messages = append(messages[:insertIdx+1], append([]llm.Message{rbMsg}, messages[insertIdx+1:]...)...) + } else { + messages = append([]llm.Message{rbMsg}, messages...) + } + return messages +} + +// followUpSuggester is the subset of *memory.MemoryManager used by +// printFollowUpSuggestions; an interface so tests can substitute a fake. +type followUpSuggester interface { + FollowUpSuggestions() []string +} + +// maxFollowUpSuggestions caps the printed suggestion block. +const maxFollowUpSuggestions = 3 + +// printFollowUpSuggestions prints a compact block of follow-up suggestions +// after a completed turn. Presentation-only: the block is written to w +// (never appended to the agent response), and only in engaging/enhance +// interaction modes — verbose and off stay machine-clean. +func printFollowUpSuggestions(w io.Writer, mm followUpSuggester, interactionMode string) { + if mm == nil { + return + } + if interactionMode != "engaging" && interactionMode != "enhance" { + return + } + fmt.Fprint(w, formatFollowUpSuggestions(mm.FollowUpSuggestions())) +} + +// formatFollowUpSuggestions renders the suggestion block, or "" when there +// are no suggestions. At most maxFollowUpSuggestions lines are included. +func formatFollowUpSuggestions(suggestions []string) string { + if len(suggestions) == 0 { + return "" + } + if len(suggestions) > maxFollowUpSuggestions { + suggestions = suggestions[:maxFollowUpSuggestions] + } + var b strings.Builder + b.WriteString("── You might also want to ──\n") + for _, s := range suggestions { + fmt.Fprintf(&b, "• %s\n", s) + } + return b.String() +} diff --git a/cmd/odek/proactive_test.go b/cmd/odek/proactive_test.go new file mode 100644 index 0000000..368d93b --- /dev/null +++ b/cmd/odek/proactive_test.go @@ -0,0 +1,308 @@ +package main + +import ( + "bytes" + "context" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/BackendStack21/odek/internal/llm" + "github.com/BackendStack21/odek/internal/memory" + "github.com/BackendStack21/odek/internal/memory/extended" +) + +// ── Item 1: follow-up suggestions ───────────────────────────────────── + +func TestFormatFollowUpSuggestions(t *testing.T) { + if got := formatFollowUpSuggestions(nil); got != "" { + t.Errorf("nil suggestions: got %q, want empty", got) + } + if got := formatFollowUpSuggestions([]string{}); got != "" { + t.Errorf("empty suggestions: got %q, want empty", got) + } + + got := formatFollowUpSuggestions([]string{"ship the release", "write the changelog"}) + want := "── You might also want to ──\n• ship the release\n• write the changelog\n" + if got != want { + t.Errorf("got %q, want %q", got, want) + } + + // Capped at maxFollowUpSuggestions. + many := []string{"one", "two", "three", "four", "five"} + got = formatFollowUpSuggestions(many) + if strings.Contains(got, "four") || strings.Contains(got, "five") { + t.Errorf("expected cap at %d suggestions, got %q", maxFollowUpSuggestions, got) + } + if strings.Count(got, "• ") != maxFollowUpSuggestions { + t.Errorf("expected %d bullet lines, got %q", maxFollowUpSuggestions, got) + } +} + +// fakeSuggester implements followUpSuggester for tests. +type fakeSuggester struct { + suggestions []string + called bool +} + +func (f *fakeSuggester) FollowUpSuggestions() []string { + f.called = true + return f.suggestions +} + +func TestPrintFollowUpSuggestions(t *testing.T) { + sugs := []string{"try X", "try Y"} + + t.Run("printed in engaging mode", func(t *testing.T) { + var buf bytes.Buffer + printFollowUpSuggestions(&buf, &fakeSuggester{suggestions: sugs}, "engaging") + if !strings.Contains(buf.String(), "── You might also want to ──") || + !strings.Contains(buf.String(), "• try X") { + t.Errorf("expected suggestion block, got %q", buf.String()) + } + }) + + t.Run("printed in enhance mode", func(t *testing.T) { + var buf bytes.Buffer + printFollowUpSuggestions(&buf, &fakeSuggester{suggestions: sugs}, "enhance") + if buf.Len() == 0 { + t.Error("expected suggestion block in enhance mode") + } + }) + + t.Run("suppressed in off mode", func(t *testing.T) { + var buf bytes.Buffer + fs := &fakeSuggester{suggestions: sugs} + printFollowUpSuggestions(&buf, fs, "off") + if buf.Len() != 0 { + t.Errorf("expected no output in off mode, got %q", buf.String()) + } + if fs.called { + t.Error("FollowUpSuggestions should not be called in off mode") + } + }) + + t.Run("suppressed in verbose mode", func(t *testing.T) { + var buf bytes.Buffer + printFollowUpSuggestions(&buf, &fakeSuggester{suggestions: sugs}, "verbose") + if buf.Len() != 0 { + t.Errorf("expected no output in verbose mode, got %q", buf.String()) + } + }) + + t.Run("absent when no suggestions", func(t *testing.T) { + var buf bytes.Buffer + printFollowUpSuggestions(&buf, &fakeSuggester{}, "engaging") + if buf.Len() != 0 { + t.Errorf("expected no output with empty suggestions, got %q", buf.String()) + } + }) + + t.Run("nil manager is a no-op", func(t *testing.T) { + var buf bytes.Buffer + printFollowUpSuggestions(&buf, nil, "engaging") + if buf.Len() != 0 { + t.Errorf("expected no output for nil manager, got %q", buf.String()) + } + }) +} + +// TestPrintFollowUpSuggestions_NoExtendedMemory: a real MemoryManager without +// Extended Memory yields no suggestions and prints nothing. +func TestPrintFollowUpSuggestions_NoExtendedMemory(t *testing.T) { + mm := memory.NewMemoryManager(t.TempDir(), nil, memory.DefaultMemoryConfig()) + var buf bytes.Buffer + printFollowUpSuggestions(&buf, mm, "engaging") + if buf.Len() != 0 { + t.Errorf("expected no output without extended memory, got %q", buf.String()) + } +} + +// ── Item 2: return-after-break injection ────────────────────────────── + +// simpleLLMServer returns a mock OpenAI-compatible endpoint whose every +// chat completion responds with the given content. +func simpleLLMServer(t *testing.T, content string) *httptest.Server { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `{"choices":[{"message":{"content":%q}}]}`, content) + })) + t.Cleanup(srv.Close) + return srv +} + +// newExtendedBackedManager seeds one trusted atom in a temp extended store, +// then returns a MemoryManager whose Extended Memory is backed by llmSrv. +func newExtendedBackedManager(t *testing.T, llmSrv *httptest.Server) *memory.MemoryManager { + t.Helper() + dir := t.TempDir() + extCfg := extended.DefaultConfig() + enabled := true + extCfg.Enabled = &enabled + + // Seed an atom via a throwaway ExtendedMemory (nil LLM suffices for writes). + seeder := extended.New(filepath.Join(dir, "extended"), nil, extCfg) + if err := seeder.AddAtom(context.Background(), extended.MemoryAtom{ + Text: "Review auth refactor", + SourceClass: extended.SourceUserSaid, + Type: extended.TypeFact, + }); err != nil { + t.Fatalf("seed atom: %v", err) + } + if err := seeder.Close(); err != nil { + t.Fatalf("close seeder: %v", err) + } + + cfg := memory.DefaultMemoryConfig() + cfg.Extended = &extCfg + mm := memory.NewMemoryManager(dir, nil, cfg) + mm.InitExtended(llm.New(llmSrv.URL, "sk-mock", "mock-model", "", 0, 30*time.Second), dir) + return mm +} + +func TestInjectReturnAfterBreak_NilManager(t *testing.T) { + msgs := []llm.Message{{Role: "system", Content: "sys"}, {Role: "user", Content: "hi"}} + out := injectReturnAfterBreak(context.Background(), nil, msgs) + if len(out) != len(msgs) { + t.Errorf("nil manager should leave messages unchanged, got %d messages", len(out)) + } +} + +func TestInjectReturnAfterBreak_ExtendedDisabled(t *testing.T) { + mm := memory.NewMemoryManager(t.TempDir(), nil, memory.DefaultMemoryConfig()) + msgs := []llm.Message{{Role: "system", Content: "sys"}, {Role: "user", Content: "hi"}} + out := injectReturnAfterBreak(context.Background(), mm, msgs) + if len(out) != len(msgs) { + t.Errorf("disabled extended memory should leave messages unchanged, got %d messages", len(out)) + } +} + +func TestInjectReturnAfterBreak_InsertsAfterLastSystem(t *testing.T) { + srv := simpleLLMServer(t, "You were reviewing the auth refactor.") + mm := newExtendedBackedManager(t, srv) + + msgs := []llm.Message{ + {Role: "system", Content: "identity"}, + {Role: "user", Content: "first"}, + {Role: "assistant", Content: "answer"}, + } + out := injectReturnAfterBreak(context.Background(), mm, msgs) + if len(out) != len(msgs)+1 { + t.Fatalf("expected %d messages, got %d", len(msgs)+1, len(out)) + } + rb := out[1] + if rb.Role != "system" { + t.Errorf("injected message role = %q, want system", rb.Role) + } + if !strings.Contains(rb.Content, `source="return_after_break"`) { + t.Errorf("injected message should be wrapped as untrusted return_after_break, got %q", rb.Content) + } + if !strings.Contains(rb.Content, "auth refactor") { + t.Errorf("injected message should contain the summary, got %q", rb.Content) + } + // Original order otherwise preserved. + if out[0].Content != "identity" || out[2].Content != "first" || out[3].Content != "answer" { + t.Errorf("message order not preserved: %+v", out) + } +} + +func TestInjectReturnAfterBreak_NoSystemMessagePrepends(t *testing.T) { + srv := simpleLLMServer(t, "You were reviewing the auth refactor.") + mm := newExtendedBackedManager(t, srv) + + msgs := []llm.Message{{Role: "user", Content: "first"}} + out := injectReturnAfterBreak(context.Background(), mm, msgs) + if len(out) != 2 || out[0].Role != "system" { + t.Fatalf("expected injected system message at index 0, got %+v", out) + } +} + +// ── Item 4: telegram nudge push ─────────────────────────────────────── + +func TestProactiveNudgesEnabled(t *testing.T) { + tru := true + fls := false + cases := []struct { + name string + cfg memory.MemoryConfig + want bool + }{ + {"no extended config", memory.MemoryConfig{}, false}, + {"extended without flag", memory.MemoryConfig{Extended: &extended.Config{}}, false}, + {"opted in", memory.MemoryConfig{Extended: &extended.Config{ProactiveNudgesEnabled: &tru}}, true}, + {"explicitly off", memory.MemoryConfig{Extended: &extended.Config{ProactiveNudgesEnabled: &fls}}, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := proactiveNudgesEnabled(tc.cfg); got != tc.want { + t.Errorf("proactiveNudgesEnabled() = %v, want %v", got, tc.want) + } + }) + } +} + +// fakeNudgeMemory implements nudgeMemory, running background work inline. +type fakeNudgeMemory struct { + nudges []extended.Nudge + err error +} + +func (f *fakeNudgeMemory) RunBackground(fn func()) { fn() } + +func (f *fakeNudgeMemory) TakeNudges(_ context.Context, maxN int) ([]extended.Nudge, error) { + if f.err != nil { + return nil, f.err + } + if maxN < len(f.nudges) { + return f.nudges[:maxN], nil + } + return f.nudges, nil +} + +func TestPushTelegramNudge(t *testing.T) { + t.Run("nudge present fires push", func(t *testing.T) { + var sent []string + mm := &fakeNudgeMemory{nudges: []extended.Nudge{ + {Text: "Your goal 'ship v2' has been quiet for a while.", Kind: extended.NudgeKindStaleGoal}, + {Text: "second nudge", Kind: extended.NudgeKindOpenQuestion}, + }} + pushTelegramNudge(mm, func(text string) { sent = append(sent, text) }) + if len(sent) != 1 { + t.Fatalf("expected exactly one nudge push, got %v", sent) + } + if !strings.HasPrefix(sent[0], "💡 ") || !strings.Contains(sent[0], "ship v2") { + t.Errorf("unexpected nudge message %q", sent[0]) + } + }) + + t.Run("no nudges stays silent", func(t *testing.T) { + sent := 0 + pushTelegramNudge(&fakeNudgeMemory{}, func(string) { sent++ }) + if sent != 0 { + t.Errorf("expected no push without nudges, got %d", sent) + } + }) + + t.Run("error stays silent", func(t *testing.T) { + sent := 0 + pushTelegramNudge(&fakeNudgeMemory{err: errors.New("boom")}, func(string) { sent++ }) + if sent != 0 { + t.Errorf("expected no push on error, got %d", sent) + } + }) + + t.Run("blank text stays silent", func(t *testing.T) { + sent := 0 + pushTelegramNudge(&fakeNudgeMemory{nudges: []extended.Nudge{{Text: " ", Kind: extended.NudgeKindDrift}}}, + func(string) { sent++ }) + if sent != 0 { + t.Errorf("expected no push for blank nudge text, got %d", sent) + } + }) +} diff --git a/cmd/odek/repl.go b/cmd/odek/repl.go index e61fe26..cd83992 100644 --- a/cmd/odek/repl.go +++ b/cmd/odek/repl.go @@ -205,6 +205,9 @@ func replCmd(args []string) error { defer cancel() turn := 0 + // resumedSession gates the one-shot return-after-break injection on the + // first turn after resuming with `odek repl --id `. + resumedSession := sessionID != "" // Line editor with history and tab completion for slash commands editor := newReplEditor( @@ -249,6 +252,14 @@ func replCmd(args []string) error { // Build message history: session messages + new user input messages := sess.GetMessages() + if resumedSession { + // Return-after-break: on session resume, inject a concise + // summary of where the user left off (first turn only). + messages = injectReturnAfterBreak(ctx, agent.Memory(), messages) + resumedSession = false + } + // origLen is computed after the (ephemeral) injection so only the + // new user/assistant turns are persisted back to the session. origLen := len(messages) messages = append(messages, llm.Message{Role: "user", Content: input}) @@ -286,6 +297,12 @@ func replCmd(args []string) error { store.Save(sess) } } + + // Follow-up suggestions after the turn (presentation-only, printed + // on stderr like the rest of the REPL's turn output; not persisted). + if mm := agent.Memory(); mm != nil { + printFollowUpSuggestions(os.Stderr, mm, resolved.InteractionMode) + } turn++ fmt.Fprintln(os.Stderr) diff --git a/cmd/odek/telegram.go b/cmd/odek/telegram.go index d288535..91a5c6b 100644 --- a/cmd/odek/telegram.go +++ b/cmd/odek/telegram.go @@ -27,6 +27,7 @@ import ( "github.com/BackendStack21/odek/internal/loop" "github.com/BackendStack21/odek/internal/memory" + "github.com/BackendStack21/odek/internal/memory/extended" "github.com/BackendStack21/odek/internal/render" "github.com/BackendStack21/odek/internal/schedule" "github.com/BackendStack21/odek/internal/session" @@ -502,6 +503,14 @@ func telegramCmd(args []string) error { if err != nil { return fmt.Sprintf("❌ %v", err), nil } + // Return-after-break: inject a concise "where you left off" + // summary into the resumed context (same as `odek continue` and + // the REPL resume path). A throwaway manager is built here because + // the /resume command runs outside any per-message agent. + resumeMM := memory.NewMemoryManager(expandHome("~/.odek/memory"), nil, resolved.Memory) + resumeMM.InitExtended(llm.New(resolved.BaseURL, resolved.APIKey, resolved.Model, "", 0, 120*time.Second), + expandHome("~/.odek/memory")) + cs.Messages = injectReturnAfterBreak(context.Background(), resumeMM, cs.Messages) taskPreview := cs.Messages[0].Content if len(taskPreview) > 80 { taskPreview = taskPreview[:80] + "…" @@ -1679,6 +1688,8 @@ func handleChatMessage( Tools: agentTools, ToolFilter: odek.ToolFilterConfig{Enabled: resolved.Tools.Enabled, Disabled: resolved.Tools.Disabled}, Renderer: rend, + MemoryConfig: resolved.Memory, + MemoryDir: expandHome("~/.odek/memory"), ToolEventHandler: func(event string, name string, data string) { // Enhance mode: send new messages with narrated descriptions. if isEnhance { @@ -2004,6 +2015,20 @@ func handleChatMessage( } } + // ── Proactive nudge push (opt-in) ── + // Runs in the background via the memory manager so nudge generation (an + // LLM call) never delays the reply; Agent.Close drains it on shutdown. + if mm := agent.Memory(); mm != nil && proactiveNudgesEnabled(resolved.Memory) { + pushTelegramNudge(mm, func(text string) { + if _, err := bot.SendMessage(chatID, telegram.EscapeMarkdown(text), &telegram.SendOpts{ + ParseMode: telegram.ParseModeMarkdownV2, + ReplyToMessageID: messageID, + }); err != nil { + fmt.Fprintf(os.Stderr, "odek telegram: nudge send chat %d: %v\n", chatID, err) + } + }) + } + // ── Learn loop: run self-improvement heuristics ── if skillsCfg != nil && skillsCfg.Learn && agent.SkillManager() != nil { sm := agent.SkillManager() @@ -2186,6 +2211,43 @@ func truncateToolArgs(data string, maxLen int) string { return data[:maxLen] + fmt.Sprintf("… [%d more bytes]", len(data)-maxLen) } +// ── Proactive Nudge Push ─────────────────────────────────────────────── + +// nudgeMemory is the subset of *memory.MemoryManager needed for the +// proactive-nudge push; an interface so tests can substitute a fake. +type nudgeMemory interface { + RunBackground(fn func()) + TakeNudges(ctx context.Context, maxN int) ([]extended.Nudge, error) +} + +// proactiveNudgesEnabled reports whether the operator opted in to proactive +// nudges (memory.extended.proactive_nudges_enabled: true). Opt-in only: the +// default (unset) is off. +func proactiveNudgesEnabled(cfg memory.MemoryConfig) bool { + return cfg.Extended != nil && + cfg.Extended.ProactiveNudgesEnabled != nil && + *cfg.Extended.ProactiveNudgesEnabled +} + +// pushTelegramNudge takes at most one proactive nudge and delivers it via +// send as a short follow-up message. The nudge generation (an LLM call) +// runs as tracked background memory work so it never delays the reply. +func pushTelegramNudge(mm nudgeMemory, send func(text string)) { + mm.RunBackground(func() { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + nudges, err := mm.TakeNudges(ctx, 1) + if err != nil || len(nudges) == 0 { + return + } + text := strings.TrimSpace(nudges[0].Text) + if text == "" { + return + } + send("💡 " + text) + }) +} + // ── Singleton Lock ───────────────────────────────────────────────────── // // Prevents two bot instances from polling Telegram simultaneously (which From 69d45f472fcdddd282592ee67cb9fbbe2c96a4c0 Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso Date: Wed, 22 Jul 2026 15:03:41 +0200 Subject: [PATCH 4/7] docs: proactive engagement (P6) - config keys, CLI, scheduler memory note --- docs/CONFIG.md | 12 ++++++++++++ docs/EXTENDED_MEMORY.md | 39 +++++++++++++++++++++++++++++++++++++++ docs/MEMORY.md | 4 +++- docs/SCHEDULES.md | 4 ++++ 4 files changed, 58 insertions(+), 1 deletion(-) diff --git a/docs/CONFIG.md b/docs/CONFIG.md index 05619fe..fcf7a27 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -133,6 +133,12 @@ Every config knob has a `ODEK_*` counterpart: | `ODEK_MEMORY_EXTENDED_MAX_SIZE_MB` | `--memory-extended-max-size-mb` | int | | `ODEK_MEMORY_EXTENDED_ATOM_MAX_CHARS` | `--memory-extended-atom-max-chars` | int | | `ODEK_MEMORY_EXTENDED_MEMORY_BUDGET_CHARS` | `--memory-extended-memory-budget-chars` | int | +| `ODEK_MEMORY_EXTENDED_FOLLOW_UP_SUGGESTIONS_ENABLED` | — | bool | +| `ODEK_MEMORY_EXTENDED_FOLLOW_UP_SUGGESTION_MIN_CONFIDENCE` | — | float | +| `ODEK_MEMORY_EXTENDED_PROACTIVE_NUDGES_ENABLED` | — | bool | +| `ODEK_MEMORY_EXTENDED_NUDGE_MAX_PER_DAY` | — | int | +| `ODEK_MEMORY_EXTENDED_NUDGE_COOLDOWN_HOURS` | — | int | +| `ODEK_MEMORY_EXTENDED_NUDGE_STALE_GOAL_DAYS` | — | int | | `ODEK_GUARD_PROVIDER` | `--guard-provider` | string | | `ODEK_GUARD_URL` | `--guard-url` | string | | `ODEK_GUARD_BATCH_URL` | `--guard-batch-url` | string | @@ -421,6 +427,12 @@ The `memory` section controls the persistent memory system (see [docs/MEMORY.md] | `predictive_intents` | `3` | — | — | Reserved for future predictive-intent recall (P5). Currently accepted but ignored. | | `auto_extract_per_turn` | `true` | — | — | Extract atoms after every user message. | | `infer_user_state` | `true` | — | — | Reserved for future user-state model inference (P3). Currently accepted but ignored. | +| `follow_up_suggestions_enabled` | `true` | `ODEK_MEMORY_EXTENDED_FOLLOW_UP_SUGGESTIONS_ENABLED` | — | Capture high-confidence predicted intents at recall time as follow-up suggestions (zero extra LLM cost). | +| `follow_up_suggestion_min_confidence` | `0.6` | `ODEK_MEMORY_EXTENDED_FOLLOW_UP_SUGGESTION_MIN_CONFIDENCE` | — | Minimum predicted-intent confidence for a follow-up suggestion. | +| `proactive_nudges_enabled` | `false` | `ODEK_MEMORY_EXTENDED_PROACTIVE_NUDGES_ENABLED` | — | Master switch for proactive nudge delivery (`TakeNudges`). Opt-in. | +| `nudge_max_per_day` | `1` | `ODEK_MEMORY_EXTENDED_NUDGE_MAX_PER_DAY` | — | Maximum proactive nudges delivered per day. | +| `nudge_cooldown_hours` | `24` | `ODEK_MEMORY_EXTENDED_NUDGE_COOLDOWN_HOURS` | — | Per-kind cooldown before a nudge of the same kind can fire again. | +| `nudge_stale_goal_days` | `7` | `ODEK_MEMORY_EXTENDED_NUDGE_STALE_GOAL_DAYS` | — | Days without activity before a goal/intent atom counts as stale for nudges. | | `llm` | omitted | — | — | Dedicated memory LLM. If omitted, the main agent LLM is reused. A warning is emitted if that model has thinking enabled. | | `embedding` | omitted | — | — | Dedicated embedding backend for atoms. If omitted, inherits `memory.embedding` or the shared top-level `embedding`. | diff --git a/docs/EXTENDED_MEMORY.md b/docs/EXTENDED_MEMORY.md index 770669f..1450a55 100644 --- a/docs/EXTENDED_MEMORY.md +++ b/docs/EXTENDED_MEMORY.md @@ -380,6 +380,12 @@ Extended Memory is configured under the `memory.extended` section. "style_mirroring_enabled": true, "anaphora_resolution_enabled": true, "follow_up_anticipation_enabled": true, + "follow_up_suggestions_enabled": true, + "follow_up_suggestion_min_confidence": 0.6, + "proactive_nudges_enabled": false, + "nudge_max_per_day": 1, + "nudge_cooldown_hours": 24, + "nudge_stale_goal_days": 7, "llm": { "base_url": "http://localhost:11434/v1", @@ -428,6 +434,12 @@ Extended Memory is configured under the `memory.extended` section. | `style_mirroring_enabled` | `true` | Inject a style-guidance directive based on the inferred user model. | | `anaphora_resolution_enabled` | `true` | Resolve the first pronoun in a user message against recent trusted atoms when the top atom's score is high enough. | | `follow_up_anticipation_enabled` | `true` | Generate predicted intents and recall atoms for them. | +| `follow_up_suggestions_enabled` | `true` | Capture high-confidence predicted intents at recall time and expose them as follow-up suggestions (zero extra LLM cost). | +| `follow_up_suggestion_min_confidence` | `0.6` | Minimum predicted-intent confidence for a follow-up suggestion to be captured. | +| `proactive_nudges_enabled` | `false` | Master switch for proactive nudge delivery via `TakeNudges`. Opt-in. | +| `nudge_max_per_day` | `1` | Maximum proactive nudges delivered per day. | +| `nudge_cooldown_hours` | `24` | Per-kind cooldown before a nudge of the same kind can fire again. | +| `nudge_stale_goal_days` | `7` | Days without activity before a goal/intent atom counts as stale for nudges. | | `llm` | omitted | Dedicated memory LLM config. **If omitted, the default global model is used.** A warning is emitted if that model has thinking enabled. | | `embedding` | omitted | Dedicated embedding backend. If omitted, uses the shared `embedding` config. | @@ -513,6 +525,32 @@ Once Extended Memory is enabled, the following proactive behaviors are active by These behaviors are always data-driven by trusted atoms and the user model, never by tainted content. +## P6 — Proactive Engagement + +P6 turns Extended Memory from a passive recall layer into a source of proactive, user-facing suggestions. All three surfaces are read-only over trusted atoms and degrade to "nothing" on any failure — a broken LLM backend or malformed response can never break a session. + +### Follow-up suggestions + +Predictive recall (P5) already generates likely follow-up intents on every recall to widen the search. P6 captures those intents instead of discarding them: after the `minPredictedIntentConfidence` (0.3) noise floor, intents at or above `follow_up_suggestion_min_confidence` (default `0.6`) are kept — at most 3 per recall — and exposed via `ExtendedMemory.LastFollowUps()` / `MemoryManager.FollowUpSuggestions()` for the CLI/Web/Telegram surfaces to render as suggested next steps. The capture costs zero extra LLM calls (it reuses the prediction the recall pipeline already paid for) and is replaced on every recall. Disable with `follow_up_suggestions_enabled: false` (recall behavior is unchanged either way). + +### Open loops + +The extraction prompt now emits `question` atoms for user questions that went unanswered in the session and `goal` atoms for stated intentions ("I want to…", "we should…", "next week I'll…"). Commands and action requests are still rejected. Exact and semantic write-path dedup collapse repeats automatically. + +`ExtendedMemory.OpenLoops(ctx, limit)` returns the trusted `question`/`goal`/`intent` atoms, newest first — the machine-readable "what is still open" view that powers the nudges engine and any presentation-layer listing. Tainted atoms are always excluded. + +### Proactive nudges + +`ExtendedMemory.ProactiveNudges(ctx, maxN)` (preview) and `ExtendedMemory.TakeNudges(ctx, maxN)` (delivery) synthesize up to `maxN` (default 2) concise, user-facing nudges from: open loops, stale goals (goal/intent atoms with no activity in `nudge_stale_goal_days`, default 7), and the user model's current focus (blockers, project drift). Each nudge carries a `kind` (`open_question`, `stale_goal`, `blocker`, `drift`) and the source atom IDs it was derived from. Synthesis is a single memory-LLM call with defensive JSON parsing; any failure returns an empty result with no error. + +Anti-annoyance caps are enforced by `TakeNudges` only and persisted to `nudges.json` in the extended directory (atomic, 0600): + +- **Master switch**: `proactive_nudges_enabled` (default `false` — strictly opt-in). +- **Daily budget**: at most `nudge_max_per_day` nudges per day (default `1`); the budget resets on day rollover, per-kind cooldowns persist. +- **Per-kind cooldown**: a kind that fired cannot fire again within `nudge_cooldown_hours` (default `24`). + +`ProactiveNudges` is a pure preview: it computes nudges without checking or recording any cap, so a CLI/Telegram "what would you nudge me about?" surface never consumes the daily budget. `MemoryManager.PreviewNudges` / `MemoryManager.TakeNudges` are nil-safe passthroughs that return empty when Extended Memory is disabled. + ## Security Architecture Extended Memory inherits and extends the provenance model from [MEMORY.md](MEMORY.md). @@ -565,6 +603,7 @@ This runs memory extraction, user-state inference, predictive intent generation, | **P3 — User-state model** | Background inference of a persistent user model, pending-review queue, user correction flow. | Implemented | | **P4 — Quarantine and promotion** | Tainted atom quarantine, inline promotion commands, `quarantine_ttl_days`, atom pinning. | Implemented | | **P5 — Predictive and proactive surfaces** | Predicted-intent recall, return-after-break summary, anaphora resolution, style mirroring, follow-up anticipation. | Implemented | +| **P6 — Proactive engagement** | Follow-up suggestions captured at recall time, open-loop (`question`/`goal`) atoms, proactive nudges engine with daily budget and per-kind cooldown. | Implemented | ## Relationship to Existing Memory diff --git a/docs/MEMORY.md b/docs/MEMORY.md index 302cccb..5909fc0 100644 --- a/docs/MEMORY.md +++ b/docs/MEMORY.md @@ -169,7 +169,9 @@ Key properties: - **Trust boundary**: per-turn extraction only produces `user_said` atoms. Tainted source classes (`tool_output`, `file_read`, `web`, `mcp`, `subagent`, `agent_generated`, `inferred`) can be stored but are quarantined and excluded from recall until promoted. - **Size cap**: defaults to 100 MB with `retention_decay` eviction; pinned atoms are never evicted. - **Tool surface**: `memory` tool actions `add_atom`, `search_atoms`, `forget_atom`, `pin_atom`, `list_quarantine`, `confirm_pending_review`, `reject_pending_review`, and `list_pending_review`. -- **CLI surface**: `odek memory extended forget|promote|pin|quarantine|compact|stats|consolidate|pending|confirm|reject`. +- **CLI surface**: `odek memory extended forget|promote|pin|quarantine|compact|stats|consolidate|nudges|pending|confirm|reject`. + +**Proactive nudges** (opt-in): when `memory.extended.proactive_nudges_enabled` is `true` (default `false`), Extended Memory can synthesize short, user-facing nudges from trusted atoms — open questions, stale goals, blockers, and drift. Delivery is capped by `nudge_max_per_day` with a per-kind cooldown (`nudge_cooldown_hours`); goals only become "stale" after `nudge_stale_goal_days`. The Telegram bot pushes at most one nudge after a completed turn (in the background, prefixed with 💡). `odek memory extended nudges` prints a preview of up to 2 nudges without consuming the daily cap. When enabled, Extended Memory atoms are injected as a separate system message after the legacy memory block and episode summaries on each turn. For the full design, config reference, and implementation status, see [docs/EXTENDED_MEMORY.md](EXTENDED_MEMORY.md). diff --git a/docs/SCHEDULES.md b/docs/SCHEDULES.md index 69d050f..837596e 100644 --- a/docs/SCHEDULES.md +++ b/docs/SCHEDULES.md @@ -12,6 +12,10 @@ container-only behaviour. # A weekday stand-up nudge delivered to Telegram odek schedule add --cron "0 9 * * 1-5" --deliver telegram "Remind me: stand-up in 15 minutes" +# A weekly proactive-nudge digest from Extended Memory (opt in with +# memory.extended.proactive_nudges_enabled: true first) +odek schedule add --cron "0 9 * * 1" --deliver telegram "Review my proactive nudges (open questions, stale goals) and summarize what deserves attention this week" + # Run the scheduler (headless), or just start `odek telegram` — it hosts one too odek schedule daemon ``` From 1e522d7a276b0541fb6a73729410e7220c1a32ae Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso Date: Wed, 22 Jul 2026 15:12:05 +0200 Subject: [PATCH 5/7] fix(cli): show follow-up suggestions for legacy/unknown interaction modes The suggestions gate only matched the literal strings engaging/enhance, but the loop treats every mode except off/verbose as engaging - so configs with a legacy or invalid interaction_mode (e.g. "all", which some configs carry from the tool_progress domain) silently suppressed the block while otherwise behaving as engaging. Gate on exclusion (only verbose/off suppress), matching the loop's interpretation. --- cmd/odek/proactive.go | 8 +++++--- cmd/odek/proactive_test.go | 12 ++++++++++++ 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/cmd/odek/proactive.go b/cmd/odek/proactive.go index d061f18..96d1f58 100644 --- a/cmd/odek/proactive.go +++ b/cmd/odek/proactive.go @@ -62,13 +62,15 @@ const maxFollowUpSuggestions = 3 // printFollowUpSuggestions prints a compact block of follow-up suggestions // after a completed turn. Presentation-only: the block is written to w -// (never appended to the agent response), and only in engaging/enhance -// interaction modes — verbose and off stay machine-clean. +// (never appended to the agent response), and suppressed only in verbose and +// off modes, which stay machine-clean. Anything else — including empty, +// unknown, or legacy mode strings — behaves like engaging, matching the +// loop's own default-engaging interpretation (internal/loop). func printFollowUpSuggestions(w io.Writer, mm followUpSuggester, interactionMode string) { if mm == nil { return } - if interactionMode != "engaging" && interactionMode != "enhance" { + if interactionMode == "verbose" || interactionMode == "off" { return } fmt.Fprint(w, formatFollowUpSuggestions(mm.FollowUpSuggestions())) diff --git a/cmd/odek/proactive_test.go b/cmd/odek/proactive_test.go index 368d93b..dcfc851 100644 --- a/cmd/odek/proactive_test.go +++ b/cmd/odek/proactive_test.go @@ -75,6 +75,18 @@ func TestPrintFollowUpSuggestions(t *testing.T) { } }) + t.Run("printed in unknown/legacy mode", func(t *testing.T) { + // Unknown or legacy mode strings (e.g. "all", "") behave like + // engaging, matching the loop's default-engaging interpretation. + for _, mode := range []string{"all", "", "something-else"} { + var buf bytes.Buffer + printFollowUpSuggestions(&buf, &fakeSuggester{suggestions: sugs}, mode) + if buf.Len() == 0 { + t.Errorf("expected suggestion block in mode %q", mode) + } + } + }) + t.Run("suppressed in off mode", func(t *testing.T) { var buf bytes.Buffer fs := &fakeSuggester{suggestions: sugs} From 83a49fff47519619ecd4a9900fa6e2dc3737ec00 Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso Date: Wed, 22 Jul 2026 15:55:22 +0200 Subject: [PATCH 6/7] chore(docker): enable proactive engagement in bundled configs Both profiles now opt into follow-up suggestions and proactive nudges (capped at 1/day with a 24h per-kind cooldown, stale goals after 7 days), matching the batteries-included intent of the compose stack - the telegram profiles get the push behavior, all profiles get follow-up suggestions after answers. --- docker/config.godmode.json | 7 ++++++- docker/config.restricted.json | 7 ++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/docker/config.godmode.json b/docker/config.godmode.json index ab8a18d..d123a20 100644 --- a/docker/config.godmode.json +++ b/docker/config.godmode.json @@ -57,7 +57,12 @@ "merge_threshold": 0.7, "add_threshold": 0.3, "extended": { - "enabled": true + "enabled": true, + "follow_up_suggestions_enabled": true, + "proactive_nudges_enabled": true, + "nudge_max_per_day": 1, + "nudge_cooldown_hours": 24, + "nudge_stale_goal_days": 7 } }, "dangerous": { diff --git a/docker/config.restricted.json b/docker/config.restricted.json index 19d98b7..4045eee 100644 --- a/docker/config.restricted.json +++ b/docker/config.restricted.json @@ -57,7 +57,12 @@ "merge_threshold": 0.7, "add_threshold": 0.3, "extended": { - "enabled": true + "enabled": true, + "follow_up_suggestions_enabled": true, + "proactive_nudges_enabled": true, + "nudge_max_per_day": 1, + "nudge_cooldown_hours": 24, + "nudge_stale_goal_days": 7 } }, "dangerous": { From ec80de2a46df66c0ad586ffc2664f3505d154772 Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso Date: Wed, 22 Jul 2026 15:56:10 +0200 Subject: [PATCH 7/7] chore(docker): raise nudge daily cap to 5 --- docker/config.godmode.json | 2 +- docker/config.restricted.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docker/config.godmode.json b/docker/config.godmode.json index d123a20..3362bc2 100644 --- a/docker/config.godmode.json +++ b/docker/config.godmode.json @@ -60,7 +60,7 @@ "enabled": true, "follow_up_suggestions_enabled": true, "proactive_nudges_enabled": true, - "nudge_max_per_day": 1, + "nudge_max_per_day": 5, "nudge_cooldown_hours": 24, "nudge_stale_goal_days": 7 } diff --git a/docker/config.restricted.json b/docker/config.restricted.json index 4045eee..2f54007 100644 --- a/docker/config.restricted.json +++ b/docker/config.restricted.json @@ -60,7 +60,7 @@ "enabled": true, "follow_up_suggestions_enabled": true, "proactive_nudges_enabled": true, - "nudge_max_per_day": 1, + "nudge_max_per_day": 5, "nudge_cooldown_hours": 24, "nudge_stale_goal_days": 7 }