Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 8 additions & 20 deletions cmd/odek/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -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})

Expand Down
32 changes: 30 additions & 2 deletions cmd/odek/memory_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ func memoryCmd(args []string) error {
// extendedMemoryCmd handles `odek memory extended <subcommand>`.
func extendedMemoryCmd(dir string, args []string) error {
if len(args) == 0 {
fmt.Fprintf(os.Stderr, "Usage: odek memory extended <forget|promote|pin|quarantine|compact|stats|consolidate|pending|confirm|reject> [args]\n")
fmt.Fprintf(os.Stderr, "Usage: odek memory extended <forget|promote|pin|quarantine|compact|stats|consolidate|nudges|pending|confirm|reject> [args]\n")
return nil
}

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
}
}
117 changes: 117 additions & 0 deletions cmd/odek/memory_cmd_test.go
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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)
}
}
94 changes: 94 additions & 0 deletions cmd/odek/proactive.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
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 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 == "verbose" || interactionMode == "off" {
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()
}
Loading
Loading