Skip to content

Commit af875d8

Browse files
authored
feat: proactive engagement — follow-up suggestions, open loops, nudges (#91)
* 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. * 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 * 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 * docs: proactive engagement (P6) - config keys, CLI, scheduler memory note * 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. * 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. * chore(docker): raise nudge daily cap to 5
1 parent 41d6301 commit af875d8

25 files changed

Lines changed: 1921 additions & 75 deletions

cmd/odek/main.go

Lines changed: 8 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1523,6 +1523,13 @@ func run(args []string) error {
15231523
fmt.Println(result)
15241524
}
15251525

1526+
// ── Follow-up suggestions: compact block after a successful turn ──
1527+
// Presentation-only (engaging/enhance modes) — never appended to the
1528+
// response string, so session/memory transcripts stay clean.
1529+
if mm := agent.Memory(); mm != nil {
1530+
printFollowUpSuggestions(os.Stdout, mm, resolved.InteractionMode)
1531+
}
1532+
15261533
return nil
15271534
}
15281535

@@ -2408,26 +2415,7 @@ func continueCmd(args []string) error {
24082415

24092416
// Return-after-break: on session resume, load a concise summary of where
24102417
// the user left off and the next likely step.
2411-
if mm := agent.Memory(); mm != nil {
2412-
rbCtx, rbCancel := context.WithTimeout(ctx, 5*time.Second)
2413-
if rb := mm.FormatReturnAfterBreak(rbCtx); rb != "" {
2414-
insertIdx := -1
2415-
for i := len(messages) - 1; i >= 0; i-- {
2416-
if messages[i].Role == "system" {
2417-
insertIdx = i
2418-
break
2419-
}
2420-
}
2421-
wrapped := wrapUntrusted(rbCtx, "return_after_break", rb)
2422-
rbMsg := llm.Message{Role: "system", Content: wrapped}
2423-
if insertIdx >= 0 {
2424-
messages = append(messages[:insertIdx+1], append([]llm.Message{rbMsg}, messages[insertIdx+1:]...)...)
2425-
} else {
2426-
messages = append([]llm.Message{rbMsg}, messages...)
2427-
}
2428-
}
2429-
rbCancel()
2430-
}
2418+
messages = injectReturnAfterBreak(ctx, agent.Memory(), messages)
24312419

24322420
messages = append(messages, llm.Message{Role: "user", Content: task})
24332421

cmd/odek/memory_cmd.go

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ func memoryCmd(args []string) error {
7878
// extendedMemoryCmd handles `odek memory extended <subcommand>`.
7979
func extendedMemoryCmd(dir string, args []string) error {
8080
if len(args) == 0 {
81-
fmt.Fprintf(os.Stderr, "Usage: odek memory extended <forget|promote|pin|quarantine|compact|stats|consolidate|pending|confirm|reject> [args]\n")
81+
fmt.Fprintf(os.Stderr, "Usage: odek memory extended <forget|promote|pin|quarantine|compact|stats|consolidate|nudges|pending|confirm|reject> [args]\n")
8282
return nil
8383
}
8484

@@ -191,6 +191,34 @@ func extendedMemoryCmd(dir string, args []string) error {
191191
fmt.Printf("odek: consolidation complete — %d atom(s) merged into existing or new entries\n", merged)
192192
return nil
193193

194+
case "nudges":
195+
// Generating nudges needs an LLM; resolve the operator backend the
196+
// same way the agent does (same pattern as consolidate).
197+
resolved := config.LoadConfig(config.CLIFlags{})
198+
if resolved.APIKey == "" {
199+
return fmt.Errorf("memory extended nudges requires an LLM backend (no API key resolved)")
200+
}
201+
llmClient := llm.New(resolved.BaseURL, resolved.APIKey, resolved.Model, "", 0, 120*time.Second)
202+
emLLM := extended.New(extDir, llmClient, cfg)
203+
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
204+
defer cancel()
205+
nudges, err := emLLM.ProactiveNudges(ctx, 2)
206+
if err != nil {
207+
return err
208+
}
209+
if len(nudges) == 0 {
210+
fmt.Println("No nudges right now.")
211+
return nil
212+
}
213+
fmt.Println("Proactive nudges (preview — does not consume the daily cap):")
214+
for _, n := range nudges {
215+
fmt.Printf("• [%s] %s\n", n.Kind, n.Text)
216+
if len(n.SourceAtomIDs) > 0 {
217+
fmt.Printf(" atoms: %s\n", strings.Join(n.SourceAtomIDs, ", "))
218+
}
219+
}
220+
return nil
221+
194222
case "pending":
195223
pending, err := em.ListPendingReview()
196224
if err != nil {
@@ -233,6 +261,6 @@ func extendedMemoryCmd(dir string, args []string) error {
233261
return nil
234262

235263
default:
236-
return fmt.Errorf("unknown extended memory subcommand %q (expected: forget, promote, pin, quarantine, compact, stats, consolidate, pending, confirm, reject)", sub)
264+
return fmt.Errorf("unknown extended memory subcommand %q (expected: forget, promote, pin, quarantine, compact, stats, consolidate, nudges, pending, confirm, reject)", sub)
237265
}
238266
}

cmd/odek/memory_cmd_test.go

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,18 @@
11
package main
22

33
import (
4+
"context"
5+
"fmt"
6+
"net/http"
7+
"net/http/httptest"
8+
"os"
49
"path/filepath"
10+
"strings"
511
"testing"
12+
"time"
613

714
"github.com/BackendStack21/odek/internal/memory"
15+
"github.com/BackendStack21/odek/internal/memory/extended"
816
)
917

1018
// TestMemoryCmd_ListAndPromote exercises the human-gated promote path end to
@@ -51,3 +59,112 @@ func TestMemoryCmd_ListEmpty(t *testing.T) {
5159
t.Fatalf("memory list on empty home: %v", err)
5260
}
5361
}
62+
63+
// ── Extended Memory: nudges preview ─────────────────────────────────
64+
65+
// unsetAPIKeys clears LLM API key env vars for the duration of a test.
66+
func unsetAPIKeys(t *testing.T) {
67+
t.Helper()
68+
for _, k := range []string{"DEEPSEEK_API_KEY", "OPENAI_API_KEY", "ODEK_API_KEY"} {
69+
orig, ok := os.LookupEnv(k)
70+
os.Unsetenv(k)
71+
if ok {
72+
t.Cleanup(func() { os.Setenv(k, orig) })
73+
}
74+
}
75+
}
76+
77+
// TestMemoryCmd_ExtendedNudges_NoBackend: nudges needs an LLM backend.
78+
func TestMemoryCmd_ExtendedNudges_NoBackend(t *testing.T) {
79+
setupTestHome(t)
80+
unsetAPIKeys(t)
81+
if err := memoryCmd([]string{"extended", "nudges"}); err == nil {
82+
t.Fatal("nudges without an API key should error")
83+
}
84+
}
85+
86+
// TestMemoryCmd_ExtendedNudges_Empty: a fresh store prints the empty message.
87+
func TestMemoryCmd_ExtendedNudges_Empty(t *testing.T) {
88+
home := setupTestHome(t)
89+
90+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
91+
w.Header().Set("Content-Type", "application/json")
92+
fmt.Fprint(w, `{"choices":[{"message":{"content":"[]"}}]}`)
93+
}))
94+
defer srv.Close()
95+
96+
os.MkdirAll(filepath.Join(home, ".odek"), 0700)
97+
cfgJSON := fmt.Sprintf(`{"base_url": %q, "api_key": "sk-mock", "model": "mock-model"}`, srv.URL)
98+
if err := os.WriteFile(filepath.Join(home, ".odek", "config.json"), []byte(cfgJSON), 0600); err != nil {
99+
t.Fatal(err)
100+
}
101+
// Env outranks config.json and shields the test from env leftovers of
102+
// earlier tests in the package.
103+
t.Setenv("ODEK_BASE_URL", srv.URL)
104+
t.Setenv("DEEPSEEK_API_KEY", "sk-mock")
105+
106+
out := captureStdout(func() {
107+
if err := memoryCmd([]string{"extended", "nudges"}); err != nil {
108+
t.Errorf("nudges on empty store: %v", err)
109+
}
110+
})
111+
if !strings.Contains(out, "No nudges right now.") {
112+
t.Errorf("expected empty-nudges message, got %q", out)
113+
}
114+
}
115+
116+
// TestMemoryCmd_ExtendedNudges_PrintsNudges: with a stale goal in the store
117+
// and an LLM that synthesizes a nudge, the preview prints kind, text, and
118+
// source atom IDs — and notes that the daily cap is not consumed.
119+
func TestMemoryCmd_ExtendedNudges_PrintsNudges(t *testing.T) {
120+
home := setupTestHome(t)
121+
122+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
123+
w.Header().Set("Content-Type", "application/json")
124+
fmt.Fprint(w, `{"choices":[{"message":{"content":"[{\"text\":\"Your goal 'ship v2' went quiet.\",\"kind\":\"stale_goal\",\"source_atom_ids\":[\"atom-1\"]}]"}}]}`)
125+
}))
126+
defer srv.Close()
127+
128+
os.MkdirAll(filepath.Join(home, ".odek"), 0700)
129+
cfgJSON := fmt.Sprintf(`{"base_url": %q, "api_key": "sk-mock", "model": "mock-model"}`, srv.URL)
130+
if err := os.WriteFile(filepath.Join(home, ".odek", "config.json"), []byte(cfgJSON), 0600); err != nil {
131+
t.Fatal(err)
132+
}
133+
// Env outranks config.json and shields the test from env leftovers of
134+
// earlier tests in the package.
135+
t.Setenv("ODEK_BASE_URL", srv.URL)
136+
t.Setenv("DEEPSEEK_API_KEY", "sk-mock")
137+
138+
// Seed a stale goal atom (old enough to clear the stale-goal threshold).
139+
extDir := filepath.Join(home, ".odek", "memory", "extended")
140+
extCfg := extended.DefaultConfig()
141+
enabled := true
142+
extCfg.Enabled = &enabled
143+
seeder := extended.New(extDir, nil, extCfg)
144+
if err := seeder.AddAtom(context.Background(), extended.MemoryAtom{
145+
Text: "Ship v2 of the API",
146+
SourceClass: extended.SourceUserSaid,
147+
Type: extended.TypeGoal,
148+
CreatedAt: time.Now().Add(-30 * 24 * time.Hour),
149+
}); err != nil {
150+
t.Fatalf("seed atom: %v", err)
151+
}
152+
if err := seeder.Close(); err != nil {
153+
t.Fatalf("close seeder: %v", err)
154+
}
155+
156+
out := captureStdout(func() {
157+
if err := memoryCmd([]string{"extended", "nudges"}); err != nil {
158+
t.Errorf("nudges: %v", err)
159+
}
160+
})
161+
if !strings.Contains(out, "• [stale_goal] Your goal 'ship v2' went quiet.") {
162+
t.Errorf("expected nudge line, got %q", out)
163+
}
164+
if !strings.Contains(out, "atoms: atom-1") {
165+
t.Errorf("expected source atom IDs, got %q", out)
166+
}
167+
if !strings.Contains(out, "does not consume the daily cap") {
168+
t.Errorf("expected preview note, got %q", out)
169+
}
170+
}

cmd/odek/proactive.go

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"io"
7+
"strings"
8+
"time"
9+
10+
"github.com/BackendStack21/odek/internal/llm"
11+
"github.com/BackendStack21/odek/internal/memory"
12+
)
13+
14+
// ── Proactive Engagement (presentation layer) ─────────────────────────
15+
//
16+
// Shared presentation helpers for the proactive engagement feature:
17+
// return-after-break injection on session resume, and follow-up
18+
// suggestions printed after a completed turn. Both are presentation-only —
19+
// nothing here is appended to the agent response or persisted into session
20+
// transcripts.
21+
22+
// injectReturnAfterBreak loads a concise "where you left off" summary from
23+
// Extended Memory and inserts it — wrapped as untrusted content — into the
24+
// message history immediately after the last system message. When there is
25+
// no summary (extended memory disabled, no atoms, LLM failure) the messages
26+
// are returned unchanged.
27+
func injectReturnAfterBreak(ctx context.Context, mm *memory.MemoryManager, messages []llm.Message) []llm.Message {
28+
if mm == nil {
29+
return messages
30+
}
31+
rbCtx, rbCancel := context.WithTimeout(ctx, 5*time.Second)
32+
defer rbCancel()
33+
rb := mm.FormatReturnAfterBreak(rbCtx)
34+
if rb == "" {
35+
return messages
36+
}
37+
insertIdx := -1
38+
for i := len(messages) - 1; i >= 0; i-- {
39+
if messages[i].Role == "system" {
40+
insertIdx = i
41+
break
42+
}
43+
}
44+
wrapped := wrapUntrusted(rbCtx, "return_after_break", rb)
45+
rbMsg := llm.Message{Role: "system", Content: wrapped}
46+
if insertIdx >= 0 {
47+
messages = append(messages[:insertIdx+1], append([]llm.Message{rbMsg}, messages[insertIdx+1:]...)...)
48+
} else {
49+
messages = append([]llm.Message{rbMsg}, messages...)
50+
}
51+
return messages
52+
}
53+
54+
// followUpSuggester is the subset of *memory.MemoryManager used by
55+
// printFollowUpSuggestions; an interface so tests can substitute a fake.
56+
type followUpSuggester interface {
57+
FollowUpSuggestions() []string
58+
}
59+
60+
// maxFollowUpSuggestions caps the printed suggestion block.
61+
const maxFollowUpSuggestions = 3
62+
63+
// printFollowUpSuggestions prints a compact block of follow-up suggestions
64+
// after a completed turn. Presentation-only: the block is written to w
65+
// (never appended to the agent response), and suppressed only in verbose and
66+
// off modes, which stay machine-clean. Anything else — including empty,
67+
// unknown, or legacy mode strings — behaves like engaging, matching the
68+
// loop's own default-engaging interpretation (internal/loop).
69+
func printFollowUpSuggestions(w io.Writer, mm followUpSuggester, interactionMode string) {
70+
if mm == nil {
71+
return
72+
}
73+
if interactionMode == "verbose" || interactionMode == "off" {
74+
return
75+
}
76+
fmt.Fprint(w, formatFollowUpSuggestions(mm.FollowUpSuggestions()))
77+
}
78+
79+
// formatFollowUpSuggestions renders the suggestion block, or "" when there
80+
// are no suggestions. At most maxFollowUpSuggestions lines are included.
81+
func formatFollowUpSuggestions(suggestions []string) string {
82+
if len(suggestions) == 0 {
83+
return ""
84+
}
85+
if len(suggestions) > maxFollowUpSuggestions {
86+
suggestions = suggestions[:maxFollowUpSuggestions]
87+
}
88+
var b strings.Builder
89+
b.WriteString("── You might also want to ──\n")
90+
for _, s := range suggestions {
91+
fmt.Fprintf(&b, "• %s\n", s)
92+
}
93+
return b.String()
94+
}

0 commit comments

Comments
 (0)