From 702f0ec3f79165ab4fdfd483ceb38a55805c0279 Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso Date: Wed, 22 Jul 2026 20:31:01 +0200 Subject: [PATCH] fix(telegram): nudge timing gate + quieter turn output Three polish issues from real bot usage: 1. Bad nudges for answered questions. Per-turn extraction creates 'question' atoms from the user message BEFORE the assistant answers, so every freshly asked question looked 'unanswered' and could be nudged immediately ('You asked about my purpose earlier - want me to answer that now?' right after it was answered). Question atoms are now age-gated: only questions older than memory.extended.nudge_open_question_min_age_hours (default 24, env ODEK_MEMORY_EXTENDED_NUDGE_OPEN_QUESTION_MIN_AGE_HOURS) are nudge candidates. Goals/intents are unaffected (own staleness window). 2. Duplicate quoted user message. The per-turn stats block and the nudge message were both sent with ReplyToMessageID while the answer message already quotes the user - three identical quotes per turn. Stats and nudges are now standalone messages. 3. Cryptic cache stats: 'cache: 0cr+0rd+0ct' is now 'cache: 0 write / 0 read / 0 total'. --- cmd/odek/telegram.go | 17 +++---- cmd/odek/telegram_test.go | 2 +- docs/CONFIG.md | 1 + docs/EXTENDED_MEMORY.md | 4 +- internal/config/loader.go | 7 +++ internal/memory/extended/config.go | 5 +++ internal/memory/extended/nudges.go | 27 +++++++++++ internal/memory/extended/proactive_test.go | 52 +++++++++++++++++++++- 8 files changed, 104 insertions(+), 11 deletions(-) diff --git a/cmd/odek/telegram.go b/cmd/odek/telegram.go index 9f4cad6..65a6fea 100644 --- a/cmd/odek/telegram.go +++ b/cmd/odek/telegram.go @@ -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) } } @@ -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) } @@ -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( diff --git a/cmd/odek/telegram_test.go b/cmd/odek/telegram_test.go index f2b3a31..b7aca8f 100644 --- a/cmd/odek/telegram_test.go +++ b/cmd/odek/telegram_test.go @@ -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") { diff --git a/docs/CONFIG.md b/docs/CONFIG.md index c888f4d..56b5637 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -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`. | diff --git a/docs/EXTENDED_MEMORY.md b/docs/EXTENDED_MEMORY.md index 1450a55..bd3fcb6 100644 --- a/docs/EXTENDED_MEMORY.md +++ b/docs/EXTENDED_MEMORY.md @@ -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", @@ -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. | @@ -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): diff --git a/internal/config/loader.go b/internal/config/loader.go index 3da1003..bcaa35f 100644 --- a/internal/config/loader.go +++ b/internal/config/loader.go @@ -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 != "" { diff --git a/internal/memory/extended/config.go b/internal/memory/extended/config.go index 5211c03..0d50683 100644 --- a/internal/memory/extended/config.go +++ b/internal/memory/extended/config.go @@ -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"` } @@ -102,6 +103,7 @@ func DefaultConfig() Config { NudgeMaxPerDay: 1, NudgeCooldownHours: 24, NudgeStaleGoalDays: 7, + NudgeOpenQuestionMinAgeHours: 24, } } @@ -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 } diff --git a/internal/memory/extended/nudges.go b/internal/memory/extended/nudges.go index dc19794..70ec747 100644 --- a/internal/memory/extended/nudges.go +++ b/internal/memory/extended/nudges.go @@ -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 { @@ -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 { diff --git a/internal/memory/extended/proactive_test.go b/internal/memory/extended/proactive_test.go index c82af3b..7041315 100644 --- a/internal/memory/extended/proactive_test.go +++ b/internal/memory/extended/proactive_test.go @@ -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 } @@ -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") + } +}