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
17 changes: 9 additions & 8 deletions cmd/odek/telegram.go
Original file line number Diff line number Diff line change
Expand Up @@ -2003,14 +2003,14 @@ func handleChatMessage(
allToolsMu.Unlock()

statsLine := formatTelegramStats(runInfo, toolList)
// No ReplyToMessageID: the answer message already quotes the
// user's text — quoting it again in the stats block doubles the
// visual noise per turn.
if _, err := bot.SendMessage(chatID, statsLine, &telegram.SendOpts{
ParseMode: telegram.ParseModeMarkdownV2,
ReplyToMessageID: messageID,
ParseMode: telegram.ParseModeMarkdownV2,
}); err != nil {
// Fallback: send as plain text so the info isn't lost
if _, err2 := bot.SendMessage(chatID, statsLine, &telegram.SendOpts{
ReplyToMessageID: messageID,
}); err2 != nil {
if _, err2 := bot.SendMessage(chatID, statsLine, nil); err2 != nil {
fmt.Fprintf(os.Stderr, "odek telegram: stats send fallback failed: %v (orig: %v)\n", err2, err)
}
}
Expand All @@ -2024,9 +2024,10 @@ func handleChatMessage(
// 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) {
// Standalone message (no reply quote): a nudge refers to older
// memory, not to the message that triggered it.
if _, err := bot.SendMessage(chatID, telegram.EscapeMarkdown(text), &telegram.SendOpts{
ParseMode: telegram.ParseModeMarkdownV2,
ReplyToMessageID: messageID,
ParseMode: telegram.ParseModeMarkdownV2,
}); err != nil {
fmt.Fprintf(os.Stderr, "odek telegram: nudge send chat %d: %v\n", chatID, err)
}
Expand Down Expand Up @@ -2138,7 +2139,7 @@ func formatTelegramStats(info loop.IterationInfo, toolList []string) string {
}

// Always include cache stats so the user can see them even when zero.
cacheStr := fmt.Sprintf(" · cache: %dcr+%drd+%dct",
cacheStr := fmt.Sprintf(" · cache: %d write / %d read / %d total",
info.CacheCreationTokens, info.CacheReadTokens, info.CachedTokens)

return fmt.Sprintf(
Expand Down
2 changes: 1 addition & 1 deletion cmd/odek/telegram_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1172,7 +1172,7 @@ func TestFormatTelegramStats(t *testing.T) {
if !strings.Contains(out, "100 in / 50 out") {
t.Errorf("missing token counts: %s", out)
}
if !strings.Contains(out, "cache: 10cr+20rd+30ct") {
if !strings.Contains(out, "cache: 10 write / 20 read / 30 total") {
t.Errorf("missing cache stats: %s", out)
}
if !strings.Contains(out, "tools: read_file, shell") {
Expand Down
1 change: 1 addition & 0 deletions docs/CONFIG.md
Original file line number Diff line number Diff line change
Expand Up @@ -433,6 +433,7 @@ The `memory` section controls the persistent memory system (see [docs/MEMORY.md]
| `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. |
| `nudge_open_question_min_age_hours` | `24` | `ODEK_MEMORY_EXTENDED_NUDGE_OPEN_QUESTION_MIN_AGE_HOURS` | — | Minimum age before a `question` atom may become a nudge candidate (younger questions are usually about to be answered). |
| `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`. |

Expand Down
4 changes: 3 additions & 1 deletion docs/EXTENDED_MEMORY.md
Original file line number Diff line number Diff line change
Expand Up @@ -386,6 +386,7 @@ Extended Memory is configured under the `memory.extended` section.
"nudge_max_per_day": 1,
"nudge_cooldown_hours": 24,
"nudge_stale_goal_days": 7,
"nudge_open_question_min_age_hours": 24,

"llm": {
"base_url": "http://localhost:11434/v1",
Expand Down Expand Up @@ -440,6 +441,7 @@ Extended Memory is configured under the `memory.extended` section.
| `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. |
| `nudge_open_question_min_age_hours` | `24` | Minimum age before a `question` atom may become a nudge candidate. Per-turn extraction runs before the assistant answers, so younger questions are almost always about to be answered — nudging about them is noise. |
| `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. |

Expand Down Expand Up @@ -541,7 +543,7 @@ The extraction prompt now emits `question` atoms for user questions that went un

### 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.
`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. `question` atoms are additionally age-gated: only questions older than `nudge_open_question_min_age_hours` (default 24) are candidates, because per-turn extraction runs before the assistant answers and a freshly asked question is almost always about to be answered.

Anti-annoyance caps are enforced by `TakeNudges` only and persisted to `nudges.json` in the extended directory (atomic, 0600):

Expand Down
7 changes: 7 additions & 0 deletions internal/config/loader.go
Original file line number Diff line number Diff line change
Expand Up @@ -1246,6 +1246,13 @@ func LoadConfig(cli CLIFlags) ResolvedConfig {
cfg.Memory.Extended = ensureExtended(cfg.Memory.Extended)
cfg.Memory.Extended.NudgeStaleGoalDays = v
}
if v := envInt("MEMORY_EXTENDED_NUDGE_OPEN_QUESTION_MIN_AGE_HOURS"); v > 0 {
if cfg.Memory == nil {
cfg.Memory = &memory.MemoryConfig{}
}
cfg.Memory.Extended = ensureExtended(cfg.Memory.Extended)
cfg.Memory.Extended.NudgeOpenQuestionMinAgeHours = v
}

// Guard env overrides
if v := envString("GUARD_PROVIDER"); v != "" {
Expand Down
5 changes: 5 additions & 0 deletions internal/memory/extended/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ type Config struct {
NudgeMaxPerDay int `json:"nudge_max_per_day,omitempty"`
NudgeCooldownHours int `json:"nudge_cooldown_hours,omitempty"`
NudgeStaleGoalDays int `json:"nudge_stale_goal_days,omitempty"`
NudgeOpenQuestionMinAgeHours int `json:"nudge_open_question_min_age_hours,omitempty"`
LLM *LLMConfig `json:"llm,omitempty"`
Embedding *embedding.Config `json:"embedding,omitempty"`
}
Expand Down Expand Up @@ -102,6 +103,7 @@ func DefaultConfig() Config {
NudgeMaxPerDay: 1,
NudgeCooldownHours: 24,
NudgeStaleGoalDays: 7,
NudgeOpenQuestionMinAgeHours: 24,
}
}

Expand Down Expand Up @@ -198,6 +200,9 @@ func Resolve(cfg Config) Config {
if cfg.NudgeStaleGoalDays > 0 {
def.NudgeStaleGoalDays = cfg.NudgeStaleGoalDays
}
if cfg.NudgeOpenQuestionMinAgeHours > 0 {
def.NudgeOpenQuestionMinAgeHours = cfg.NudgeOpenQuestionMinAgeHours
}
if cfg.LLM != nil {
def.LLM = cfg.LLM
}
Expand Down
27 changes: 27 additions & 0 deletions internal/memory/extended/nudges.go
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,24 @@ func (em *ExtendedMemory) computeNudges(ctx context.Context, maxN int) ([]Nudge,
log.Printf("extended memory: nudge open-loops failed: %v", err)
return nil, nil
}
// Age-gate question atoms: per-turn extraction runs on the user message
// BEFORE the assistant answers, so every freshly asked question looks
// "unanswered" at extraction time. Nudging about a question that was just
// answered (or is still being discussed) is exactly the kind of proactive
// noise that destroys trust, so only questions older than
// NudgeOpenQuestionMinAgeHours are candidates. Goals and intents are
// unaffected — they have their own staleness window.
minAge := time.Duration(em.openQuestionMinAgeHours()) * time.Hour
cutoff := time.Now().UTC().Add(-minAge)
candidates := openLoops[:0]
for _, a := range openLoops {
if a.Type == TypeQuestion && a.CreatedAt.After(cutoff) {
continue
}
candidates = append(candidates, a)
}
openLoops = candidates

stale := em.staleGoals()
var focus FocusState
if em.userModel != nil {
Expand Down Expand Up @@ -270,6 +288,15 @@ func (em *ExtendedMemory) staleGoalDays() int {
return DefaultConfig().NudgeStaleGoalDays
}

// openQuestionMinAgeHours returns the minimum age before a question atom may
// become a nudge candidate.
func (em *ExtendedMemory) openQuestionMinAgeHours() int {
if em.cfg.NudgeOpenQuestionMinAgeHours > 0 {
return em.cfg.NudgeOpenQuestionMinAgeHours
}
return DefaultConfig().NudgeOpenQuestionMinAgeHours
}

// 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 {
Expand Down
52 changes: 51 additions & 1 deletion internal/memory/extended/proactive_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,8 @@ func newNudgeEM(t *testing.T, llm LLMClient, mutate func(*Config)) *ExtendedMemo
mutate(c)
}
})
seedAtom(t, em, nudgeTestAtomID, "How does the eviction policy work?", TypeQuestion, time.Hour)
// Seeded 48h old so it passes the default 24h open-question nudge gate.
seedAtom(t, em, nudgeTestAtomID, "How does the eviction policy work?", TypeQuestion, 48*time.Hour)
return em
}

Expand Down Expand Up @@ -514,3 +515,52 @@ func TestNudgeStateRoundTrip(t *testing.T) {
t.Errorf("last_fired_by_kind = %v, want %v", got.LastFiredByKind, want.LastFiredByKind)
}
}

// TestNudgeOpenQuestionAgeGate verifies freshly asked questions never become
// nudge candidates: per-turn extraction runs before the assistant answers, so
// a young "unanswered" question is usually about to be answered — nudging
// about it is noise. Only questions older than the configured minimum age
// reach the synthesis prompt; goals/intents are unaffected.
func TestNudgeOpenQuestionAgeGate(t *testing.T) {
llm := newMockLLM(`[]`)
em := newProactiveEM(t, llm, func(c *Config) { c.ProactiveNudgesEnabled = boolPtr(true) })
seedAtom(t, em, "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb2", "What is the fresh question?", TypeQuestion, time.Hour)
seedAtom(t, em, "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb3", "What is the ancient question?", TypeQuestion, 72*time.Hour)
seedAtom(t, em, "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb4", "I plan to refactor the scheduler.", TypeGoal, time.Hour)

if _, err := em.ProactiveNudges(context.Background(), 2); err != nil {
t.Fatalf("ProactiveNudges failed: %v", err)
}
prompt := llm.lastUserPrompt()
if strings.Contains(prompt, "fresh question") {
t.Errorf("1h-old question should be gated out of the nudge prompt:\n%s", prompt)
}
if !strings.Contains(prompt, "ancient question") {
t.Errorf("72h-old question should reach the nudge prompt:\n%s", prompt)
}
if !strings.Contains(prompt, "refactor the scheduler") {
t.Errorf("young goal atom should be unaffected by the question gate:\n%s", prompt)
}
}

// TestNudgeOpenQuestionAgeGateConfigurable verifies the gate honors a custom
// minimum age (and that non-positive values fall back to the default).
func TestNudgeOpenQuestionAgeGateConfigurable(t *testing.T) {
llm := newMockLLM(`[]`)
em := newProactiveEM(t, llm, func(c *Config) {
c.ProactiveNudgesEnabled = boolPtr(true)
c.NudgeOpenQuestionMinAgeHours = 2
})
seedAtom(t, em, "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb2", "What is the fresh question?", TypeQuestion, time.Hour)
seedAtom(t, em, "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb3", "What is the older question?", TypeQuestion, 3*time.Hour)
if _, err := em.ProactiveNudges(context.Background(), 2); err != nil {
t.Fatalf("ProactiveNudges failed: %v", err)
}
prompt := llm.lastUserPrompt()
if strings.Contains(prompt, "fresh question") {
t.Errorf("1h-old question should be gated out with a 2h gate")
}
if !strings.Contains(prompt, "older question") {
t.Errorf("3h-old question should pass a 2h gate")
}
}
Loading