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") + } +}