Skip to content

Commit 4b78f1a

Browse files
committed
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
1 parent 892e755 commit 4b78f1a

10 files changed

Lines changed: 1124 additions & 50 deletions

File tree

internal/config/loader.go

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1103,6 +1103,48 @@ func LoadConfig(cli CLIFlags) ResolvedConfig {
11031103
cfg.Memory.Extended = ensureExtended(cfg.Memory.Extended)
11041104
cfg.Memory.Extended.FollowUpAnticipationEnabled = v
11051105
}
1106+
if v := envBool("MEMORY_EXTENDED_FOLLOW_UP_SUGGESTIONS_ENABLED"); v != nil {
1107+
if cfg.Memory == nil {
1108+
cfg.Memory = &memory.MemoryConfig{}
1109+
}
1110+
cfg.Memory.Extended = ensureExtended(cfg.Memory.Extended)
1111+
cfg.Memory.Extended.FollowUpSuggestionsEnabled = v
1112+
}
1113+
if v := envFloat("MEMORY_EXTENDED_FOLLOW_UP_SUGGESTION_MIN_CONFIDENCE"); v > 0 {
1114+
if cfg.Memory == nil {
1115+
cfg.Memory = &memory.MemoryConfig{}
1116+
}
1117+
cfg.Memory.Extended = ensureExtended(cfg.Memory.Extended)
1118+
cfg.Memory.Extended.FollowUpSuggestionMinConfidence = float32(v)
1119+
}
1120+
if v := envBool("MEMORY_EXTENDED_PROACTIVE_NUDGES_ENABLED"); v != nil {
1121+
if cfg.Memory == nil {
1122+
cfg.Memory = &memory.MemoryConfig{}
1123+
}
1124+
cfg.Memory.Extended = ensureExtended(cfg.Memory.Extended)
1125+
cfg.Memory.Extended.ProactiveNudgesEnabled = v
1126+
}
1127+
if v := envInt("MEMORY_EXTENDED_NUDGE_MAX_PER_DAY"); v > 0 {
1128+
if cfg.Memory == nil {
1129+
cfg.Memory = &memory.MemoryConfig{}
1130+
}
1131+
cfg.Memory.Extended = ensureExtended(cfg.Memory.Extended)
1132+
cfg.Memory.Extended.NudgeMaxPerDay = v
1133+
}
1134+
if v := envInt("MEMORY_EXTENDED_NUDGE_COOLDOWN_HOURS"); v > 0 {
1135+
if cfg.Memory == nil {
1136+
cfg.Memory = &memory.MemoryConfig{}
1137+
}
1138+
cfg.Memory.Extended = ensureExtended(cfg.Memory.Extended)
1139+
cfg.Memory.Extended.NudgeCooldownHours = v
1140+
}
1141+
if v := envInt("MEMORY_EXTENDED_NUDGE_STALE_GOAL_DAYS"); v > 0 {
1142+
if cfg.Memory == nil {
1143+
cfg.Memory = &memory.MemoryConfig{}
1144+
}
1145+
cfg.Memory.Extended = ensureExtended(cfg.Memory.Extended)
1146+
cfg.Memory.Extended.NudgeStaleGoalDays = v
1147+
}
11061148

11071149
// Guard env overrides
11081150
if v := envString("GUARD_PROVIDER"); v != "" {

internal/config/loader_test.go

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1557,6 +1557,39 @@ func TestLoadConfig_ExtendedMemoryEnv(t *testing.T) {
15571557
}
15581558
}
15591559

1560+
func TestLoadConfig_ExtendedMemoryProactiveEnv(t *testing.T) {
1561+
t.Setenv("HOME", t.TempDir())
1562+
t.Setenv("ODEK_MEMORY_EXTENDED_FOLLOW_UP_SUGGESTIONS_ENABLED", "false")
1563+
t.Setenv("ODEK_MEMORY_EXTENDED_FOLLOW_UP_SUGGESTION_MIN_CONFIDENCE", "0.75")
1564+
t.Setenv("ODEK_MEMORY_EXTENDED_PROACTIVE_NUDGES_ENABLED", "true")
1565+
t.Setenv("ODEK_MEMORY_EXTENDED_NUDGE_MAX_PER_DAY", "3")
1566+
t.Setenv("ODEK_MEMORY_EXTENDED_NUDGE_COOLDOWN_HOURS", "12")
1567+
t.Setenv("ODEK_MEMORY_EXTENDED_NUDGE_STALE_GOAL_DAYS", "14")
1568+
cfg := LoadConfig(CLIFlags{})
1569+
if cfg.Memory.Extended == nil {
1570+
t.Fatal("Extended memory config not loaded from env")
1571+
}
1572+
ext := cfg.Memory.Extended
1573+
if ext.FollowUpSuggestionsEnabled == nil || *ext.FollowUpSuggestionsEnabled {
1574+
t.Error("FollowUpSuggestionsEnabled should be false")
1575+
}
1576+
if ext.FollowUpSuggestionMinConfidence != 0.75 {
1577+
t.Errorf("FollowUpSuggestionMinConfidence = %v, want 0.75", ext.FollowUpSuggestionMinConfidence)
1578+
}
1579+
if ext.ProactiveNudgesEnabled == nil || !*ext.ProactiveNudgesEnabled {
1580+
t.Error("ProactiveNudgesEnabled should be true")
1581+
}
1582+
if ext.NudgeMaxPerDay != 3 {
1583+
t.Errorf("NudgeMaxPerDay = %d, want 3", ext.NudgeMaxPerDay)
1584+
}
1585+
if ext.NudgeCooldownHours != 12 {
1586+
t.Errorf("NudgeCooldownHours = %d, want 12", ext.NudgeCooldownHours)
1587+
}
1588+
if ext.NudgeStaleGoalDays != 14 {
1589+
t.Errorf("NudgeStaleGoalDays = %d, want 14", ext.NudgeStaleGoalDays)
1590+
}
1591+
}
1592+
15601593
func TestLoadConfig_ExtendedMemoryCLIOverridesEnv(t *testing.T) {
15611594
t.Setenv("HOME", t.TempDir())
15621595
t.Setenv("ODEK_MEMORY_EXTENDED_MAX_SIZE_MB", "200")

internal/memory/extended/config.go

Lines changed: 80 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -16,32 +16,38 @@ import (
1616

1717
// Config controls the Extended Memory subsystem.
1818
type Config struct {
19-
Enabled *bool `json:"enabled,omitempty"`
20-
MaxSizeMB int `json:"max_size_mb,omitempty"`
21-
SemanticSearchTopK int `json:"semantic_search_top_k,omitempty"`
22-
SemanticSearchOverfetch int `json:"semantic_search_overfetch,omitempty"`
23-
SemanticSearchMinScore float32 `json:"semantic_search_min_score,omitempty"`
24-
SemanticSearchRerank *bool `json:"semantic_search_rerank,omitempty"`
25-
AtomMaxChars int `json:"atom_max_chars,omitempty"`
26-
MemoryBudgetChars int `json:"memory_budget_chars,omitempty"`
27-
DecayHalfLifeDays int `json:"decay_half_life_days,omitempty"`
28-
QuarantineTTLDays int `json:"quarantine_ttl_days,omitempty"`
29-
EvictionPolicy string `json:"eviction_policy,omitempty"`
30-
PredictiveIntents int `json:"predictive_intents,omitempty"`
31-
AutoExtractPerTurn *bool `json:"auto_extract_per_turn,omitempty"`
32-
InferUserState *bool `json:"infer_user_state,omitempty"`
33-
UserStateTurnInterval int `json:"user_state_turn_interval,omitempty"`
34-
UserStateMaxPending int `json:"user_state_max_pending,omitempty"`
35-
AssociationsEnabled *bool `json:"associations_enabled,omitempty"`
36-
AssociationSemanticTopK int `json:"association_semantic_top_k,omitempty"`
37-
SemanticDedupThreshold *float32 `json:"semantic_dedup_threshold,omitempty"`
38-
ConsolidateSimilarityThreshold float32 `json:"consolidate_similarity_threshold,omitempty"`
39-
ProactiveReturnAfterBreak *bool `json:"proactive_return_after_break,omitempty"`
40-
StyleMirroringEnabled *bool `json:"style_mirroring_enabled,omitempty"`
41-
AnaphoraResolutionEnabled *bool `json:"anaphora_resolution_enabled,omitempty"`
42-
FollowUpAnticipationEnabled *bool `json:"follow_up_anticipation_enabled,omitempty"`
43-
LLM *LLMConfig `json:"llm,omitempty"`
44-
Embedding *embedding.Config `json:"embedding,omitempty"`
19+
Enabled *bool `json:"enabled,omitempty"`
20+
MaxSizeMB int `json:"max_size_mb,omitempty"`
21+
SemanticSearchTopK int `json:"semantic_search_top_k,omitempty"`
22+
SemanticSearchOverfetch int `json:"semantic_search_overfetch,omitempty"`
23+
SemanticSearchMinScore float32 `json:"semantic_search_min_score,omitempty"`
24+
SemanticSearchRerank *bool `json:"semantic_search_rerank,omitempty"`
25+
AtomMaxChars int `json:"atom_max_chars,omitempty"`
26+
MemoryBudgetChars int `json:"memory_budget_chars,omitempty"`
27+
DecayHalfLifeDays int `json:"decay_half_life_days,omitempty"`
28+
QuarantineTTLDays int `json:"quarantine_ttl_days,omitempty"`
29+
EvictionPolicy string `json:"eviction_policy,omitempty"`
30+
PredictiveIntents int `json:"predictive_intents,omitempty"`
31+
AutoExtractPerTurn *bool `json:"auto_extract_per_turn,omitempty"`
32+
InferUserState *bool `json:"infer_user_state,omitempty"`
33+
UserStateTurnInterval int `json:"user_state_turn_interval,omitempty"`
34+
UserStateMaxPending int `json:"user_state_max_pending,omitempty"`
35+
AssociationsEnabled *bool `json:"associations_enabled,omitempty"`
36+
AssociationSemanticTopK int `json:"association_semantic_top_k,omitempty"`
37+
SemanticDedupThreshold *float32 `json:"semantic_dedup_threshold,omitempty"`
38+
ConsolidateSimilarityThreshold float32 `json:"consolidate_similarity_threshold,omitempty"`
39+
ProactiveReturnAfterBreak *bool `json:"proactive_return_after_break,omitempty"`
40+
StyleMirroringEnabled *bool `json:"style_mirroring_enabled,omitempty"`
41+
AnaphoraResolutionEnabled *bool `json:"anaphora_resolution_enabled,omitempty"`
42+
FollowUpAnticipationEnabled *bool `json:"follow_up_anticipation_enabled,omitempty"`
43+
FollowUpSuggestionsEnabled *bool `json:"follow_up_suggestions_enabled,omitempty"`
44+
FollowUpSuggestionMinConfidence float32 `json:"follow_up_suggestion_min_confidence,omitempty"`
45+
ProactiveNudgesEnabled *bool `json:"proactive_nudges_enabled,omitempty"`
46+
NudgeMaxPerDay int `json:"nudge_max_per_day,omitempty"`
47+
NudgeCooldownHours int `json:"nudge_cooldown_hours,omitempty"`
48+
NudgeStaleGoalDays int `json:"nudge_stale_goal_days,omitempty"`
49+
LLM *LLMConfig `json:"llm,omitempty"`
50+
Embedding *embedding.Config `json:"embedding,omitempty"`
4551
}
4652

4753
// LLMConfig selects a dedicated LLM for Extended Memory extraction and
@@ -66,30 +72,36 @@ func floatPtr(f float32) *float32 { return &f }
6672
// Extended Memory is opt-in: Enabled defaults to false.
6773
func DefaultConfig() Config {
6874
return Config{
69-
Enabled: boolPtr(false),
70-
MaxSizeMB: 100,
71-
SemanticSearchTopK: 10,
72-
SemanticSearchOverfetch: 4,
73-
SemanticSearchMinScore: 0.55,
74-
SemanticSearchRerank: boolPtr(true),
75-
AtomMaxChars: 300,
76-
MemoryBudgetChars: 2000,
77-
DecayHalfLifeDays: 30,
78-
QuarantineTTLDays: 7,
79-
EvictionPolicy: "retention_decay",
80-
PredictiveIntents: 3,
81-
AutoExtractPerTurn: boolPtr(true),
82-
InferUserState: boolPtr(true),
83-
UserStateTurnInterval: 5,
84-
UserStateMaxPending: 20,
85-
AssociationsEnabled: boolPtr(true),
86-
AssociationSemanticTopK: 3,
87-
SemanticDedupThreshold: floatPtr(0.92),
88-
ConsolidateSimilarityThreshold: 0.9,
89-
ProactiveReturnAfterBreak: boolPtr(true),
90-
StyleMirroringEnabled: boolPtr(true),
91-
AnaphoraResolutionEnabled: boolPtr(true),
92-
FollowUpAnticipationEnabled: boolPtr(true),
75+
Enabled: boolPtr(false),
76+
MaxSizeMB: 100,
77+
SemanticSearchTopK: 10,
78+
SemanticSearchOverfetch: 4,
79+
SemanticSearchMinScore: 0.55,
80+
SemanticSearchRerank: boolPtr(true),
81+
AtomMaxChars: 300,
82+
MemoryBudgetChars: 2000,
83+
DecayHalfLifeDays: 30,
84+
QuarantineTTLDays: 7,
85+
EvictionPolicy: "retention_decay",
86+
PredictiveIntents: 3,
87+
AutoExtractPerTurn: boolPtr(true),
88+
InferUserState: boolPtr(true),
89+
UserStateTurnInterval: 5,
90+
UserStateMaxPending: 20,
91+
AssociationsEnabled: boolPtr(true),
92+
AssociationSemanticTopK: 3,
93+
SemanticDedupThreshold: floatPtr(0.92),
94+
ConsolidateSimilarityThreshold: 0.9,
95+
ProactiveReturnAfterBreak: boolPtr(true),
96+
StyleMirroringEnabled: boolPtr(true),
97+
AnaphoraResolutionEnabled: boolPtr(true),
98+
FollowUpAnticipationEnabled: boolPtr(true),
99+
FollowUpSuggestionsEnabled: boolPtr(true),
100+
FollowUpSuggestionMinConfidence: 0.6,
101+
ProactiveNudgesEnabled: boolPtr(false), // opt-in: proactive features never fire uninvited
102+
NudgeMaxPerDay: 1,
103+
NudgeCooldownHours: 24,
104+
NudgeStaleGoalDays: 7,
93105
}
94106
}
95107

@@ -168,6 +180,24 @@ func Resolve(cfg Config) Config {
168180
if cfg.FollowUpAnticipationEnabled != nil {
169181
def.FollowUpAnticipationEnabled = cfg.FollowUpAnticipationEnabled
170182
}
183+
if cfg.FollowUpSuggestionsEnabled != nil {
184+
def.FollowUpSuggestionsEnabled = cfg.FollowUpSuggestionsEnabled
185+
}
186+
if cfg.FollowUpSuggestionMinConfidence > 0 && cfg.FollowUpSuggestionMinConfidence <= 1 {
187+
def.FollowUpSuggestionMinConfidence = cfg.FollowUpSuggestionMinConfidence
188+
}
189+
if cfg.ProactiveNudgesEnabled != nil {
190+
def.ProactiveNudgesEnabled = cfg.ProactiveNudgesEnabled
191+
}
192+
if cfg.NudgeMaxPerDay > 0 {
193+
def.NudgeMaxPerDay = cfg.NudgeMaxPerDay
194+
}
195+
if cfg.NudgeCooldownHours > 0 {
196+
def.NudgeCooldownHours = cfg.NudgeCooldownHours
197+
}
198+
if cfg.NudgeStaleGoalDays > 0 {
199+
def.NudgeStaleGoalDays = cfg.NudgeStaleGoalDays
200+
}
171201
if cfg.LLM != nil {
172202
def.LLM = cfg.LLM
173203
}

internal/memory/extended/extended_memory.go

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,15 @@ type ExtendedMemory struct {
4949
recentUserMessages []string
5050
recentMu sync.Mutex
5151

52+
// lastFollowUps holds the high-confidence predicted intents captured
53+
// during the most recent recall, surfaced as follow-up suggestions.
54+
followUpsMu sync.Mutex
55+
lastFollowUps []PredictedIntent
56+
57+
// nudgeMu serializes TakeNudges so concurrent takers cannot both spend
58+
// the same daily budget.
59+
nudgeMu sync.Mutex
60+
5261
inferenceMu sync.Mutex
5362
closed bool
5463
inferRunning bool
@@ -84,6 +93,7 @@ func New(dir string, llm LLMClient, cfg Config) *ExtendedMemory {
8493
llm: llm,
8594
}
8695
em.recall.SetPredictor(em.predictor)
96+
em.recall.SetFollowUpSink(em.setLastFollowUps)
8797
em.recall.stats = &em.stats
8898
_ = em.userModel.Load()
8999
em.quarantine.SetTTLDays(cfg.QuarantineTTLDays)
@@ -137,6 +147,28 @@ func (em *ExtendedMemory) SetSessionContext(sessionID, project string) {
137147
em.project = project
138148
}
139149

150+
// setLastFollowUps replaces the captured follow-up suggestions. It is the
151+
// sink wired into Recall so every recall refreshes the list; a nil slice
152+
// clears it.
153+
func (em *ExtendedMemory) setLastFollowUps(intents []PredictedIntent) {
154+
em.followUpsMu.Lock()
155+
defer em.followUpsMu.Unlock()
156+
em.lastFollowUps = intents
157+
}
158+
159+
// LastFollowUps returns a copy of the predicted follow-up intents captured
160+
// during the most recent recall, or nil when none were captured.
161+
func (em *ExtendedMemory) LastFollowUps() []PredictedIntent {
162+
if em == nil {
163+
return nil
164+
}
165+
em.followUpsMu.Lock()
166+
defer em.followUpsMu.Unlock()
167+
out := make([]PredictedIntent, len(em.lastFollowUps))
168+
copy(out, em.lastFollowUps)
169+
return out
170+
}
171+
140172
// AddAtom manually adds an atom. Manual adds are treated as user-approved.
141173
func (em *ExtendedMemory) AddAtom(ctx context.Context, atom MemoryAtom) error {
142174
return em.addAtoms(ctx, []MemoryAtom{atom}, false)
@@ -600,6 +632,40 @@ func (em *ExtendedMemory) AnaphoraResolve(ctx context.Context, msg string) (stri
600632
return resolved, true
601633
}
602634

635+
// openLoopTypes are the atom types that represent unresolved threads: user
636+
// questions that went unanswered and stated goals/intentions.
637+
var openLoopTypes = map[string]bool{
638+
TypeQuestion: true,
639+
TypeGoal: true,
640+
TypeIntent: true,
641+
}
642+
643+
// OpenLoops returns trusted question/goal/intent atoms, newest first, capped
644+
// at limit (limit <= 0 returns all). It lists the store directly instead of
645+
// running a semantic query: open loops are a recency-ordered data view, so
646+
// the embedding search, min-score filtering, and LLM rerank of the recall
647+
// pipeline would only add cost without improving the answer.
648+
func (em *ExtendedMemory) OpenLoops(ctx context.Context, limit int) ([]MemoryAtom, error) {
649+
if em == nil || !em.Enabled() {
650+
return nil, nil
651+
}
652+
atoms, err := em.store.List() // newest first
653+
if err != nil {
654+
return nil, fmt.Errorf("extended memory: open loops list: %w", err)
655+
}
656+
out := make([]MemoryAtom, 0, len(atoms))
657+
for _, atom := range atoms {
658+
if !openLoopTypes[atom.Type] || IsTaintedSourceClass(atom.SourceClass) {
659+
continue
660+
}
661+
out = append(out, atom)
662+
if limit > 0 && len(out) >= limit {
663+
break
664+
}
665+
}
666+
return out, nil
667+
}
668+
603669
// SearchAtoms performs an explicit semantic search and returns ranked atoms.
604670
func (em *ExtendedMemory) SearchAtoms(ctx context.Context, query string) ([]MemoryAtom, error) {
605671
if em == nil {

internal/memory/extended/extractor.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ For each atom, output an object with:
4444
Rules:
4545
- Only extract stable information worth recalling in future sessions.
4646
- Do NOT extract instructions, commands, or requests to perform actions.
47+
- 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.
48+
- DO emit a "goal" atom when the user states an intention or plan (e.g. "I want to…", "we should…", "next week I'll…").
4749
- Do NOT extract ephemeral details specific only to this message.
4850
- If nothing durable is present, return an empty array.
4951

0 commit comments

Comments
 (0)