Skip to content

Commit db639eb

Browse files
authored
fix(telegram): nudge timing gate + quieter turn output (#93)
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'.
1 parent e4c1d95 commit db639eb

8 files changed

Lines changed: 104 additions & 11 deletions

File tree

cmd/odek/telegram.go

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2003,14 +2003,14 @@ func handleChatMessage(
20032003
allToolsMu.Unlock()
20042004

20052005
statsLine := formatTelegramStats(runInfo, toolList)
2006+
// No ReplyToMessageID: the answer message already quotes the
2007+
// user's text — quoting it again in the stats block doubles the
2008+
// visual noise per turn.
20062009
if _, err := bot.SendMessage(chatID, statsLine, &telegram.SendOpts{
2007-
ParseMode: telegram.ParseModeMarkdownV2,
2008-
ReplyToMessageID: messageID,
2010+
ParseMode: telegram.ParseModeMarkdownV2,
20092011
}); err != nil {
20102012
// Fallback: send as plain text so the info isn't lost
2011-
if _, err2 := bot.SendMessage(chatID, statsLine, &telegram.SendOpts{
2012-
ReplyToMessageID: messageID,
2013-
}); err2 != nil {
2013+
if _, err2 := bot.SendMessage(chatID, statsLine, nil); err2 != nil {
20142014
fmt.Fprintf(os.Stderr, "odek telegram: stats send fallback failed: %v (orig: %v)\n", err2, err)
20152015
}
20162016
}
@@ -2024,9 +2024,10 @@ func handleChatMessage(
20242024
// LLM call) never delays the reply; Agent.Close drains it on shutdown.
20252025
if mm := agent.Memory(); mm != nil && proactiveNudgesEnabled(resolved.Memory) {
20262026
pushTelegramNudge(mm, func(text string) {
2027+
// Standalone message (no reply quote): a nudge refers to older
2028+
// memory, not to the message that triggered it.
20272029
if _, err := bot.SendMessage(chatID, telegram.EscapeMarkdown(text), &telegram.SendOpts{
2028-
ParseMode: telegram.ParseModeMarkdownV2,
2029-
ReplyToMessageID: messageID,
2030+
ParseMode: telegram.ParseModeMarkdownV2,
20302031
}); err != nil {
20312032
fmt.Fprintf(os.Stderr, "odek telegram: nudge send chat %d: %v\n", chatID, err)
20322033
}
@@ -2138,7 +2139,7 @@ func formatTelegramStats(info loop.IterationInfo, toolList []string) string {
21382139
}
21392140

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

21442145
return fmt.Sprintf(

cmd/odek/telegram_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1172,7 +1172,7 @@ func TestFormatTelegramStats(t *testing.T) {
11721172
if !strings.Contains(out, "100 in / 50 out") {
11731173
t.Errorf("missing token counts: %s", out)
11741174
}
1175-
if !strings.Contains(out, "cache: 10cr+20rd+30ct") {
1175+
if !strings.Contains(out, "cache: 10 write / 20 read / 30 total") {
11761176
t.Errorf("missing cache stats: %s", out)
11771177
}
11781178
if !strings.Contains(out, "tools: read_file, shell") {

docs/CONFIG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -433,6 +433,7 @@ The `memory` section controls the persistent memory system (see [docs/MEMORY.md]
433433
| `nudge_max_per_day` | `1` | `ODEK_MEMORY_EXTENDED_NUDGE_MAX_PER_DAY` || Maximum proactive nudges delivered per day. |
434434
| `nudge_cooldown_hours` | `24` | `ODEK_MEMORY_EXTENDED_NUDGE_COOLDOWN_HOURS` || Per-kind cooldown before a nudge of the same kind can fire again. |
435435
| `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. |
436+
| `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). |
436437
| `llm` | omitted ||| Dedicated memory LLM. If omitted, the main agent LLM is reused. A warning is emitted if that model has thinking enabled. |
437438
| `embedding` | omitted ||| Dedicated embedding backend for atoms. If omitted, inherits `memory.embedding` or the shared top-level `embedding`. |
438439

docs/EXTENDED_MEMORY.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -386,6 +386,7 @@ Extended Memory is configured under the `memory.extended` section.
386386
"nudge_max_per_day": 1,
387387
"nudge_cooldown_hours": 24,
388388
"nudge_stale_goal_days": 7,
389+
"nudge_open_question_min_age_hours": 24,
389390

390391
"llm": {
391392
"base_url": "http://localhost:11434/v1",
@@ -440,6 +441,7 @@ Extended Memory is configured under the `memory.extended` section.
440441
| `nudge_max_per_day` | `1` | Maximum proactive nudges delivered per day. |
441442
| `nudge_cooldown_hours` | `24` | Per-kind cooldown before a nudge of the same kind can fire again. |
442443
| `nudge_stale_goal_days` | `7` | Days without activity before a goal/intent atom counts as stale for nudges. |
444+
| `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. |
443445
| `llm` | omitted | Dedicated memory LLM config. **If omitted, the default global model is used.** A warning is emitted if that model has thinking enabled. |
444446
| `embedding` | omitted | Dedicated embedding backend. If omitted, uses the shared `embedding` config. |
445447

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

542544
### Proactive nudges
543545

544-
`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.
546+
`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.
545547

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

internal/config/loader.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1246,6 +1246,13 @@ func LoadConfig(cli CLIFlags) ResolvedConfig {
12461246
cfg.Memory.Extended = ensureExtended(cfg.Memory.Extended)
12471247
cfg.Memory.Extended.NudgeStaleGoalDays = v
12481248
}
1249+
if v := envInt("MEMORY_EXTENDED_NUDGE_OPEN_QUESTION_MIN_AGE_HOURS"); v > 0 {
1250+
if cfg.Memory == nil {
1251+
cfg.Memory = &memory.MemoryConfig{}
1252+
}
1253+
cfg.Memory.Extended = ensureExtended(cfg.Memory.Extended)
1254+
cfg.Memory.Extended.NudgeOpenQuestionMinAgeHours = v
1255+
}
12491256

12501257
// Guard env overrides
12511258
if v := envString("GUARD_PROVIDER"); v != "" {

internal/memory/extended/config.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ type Config struct {
4646
NudgeMaxPerDay int `json:"nudge_max_per_day,omitempty"`
4747
NudgeCooldownHours int `json:"nudge_cooldown_hours,omitempty"`
4848
NudgeStaleGoalDays int `json:"nudge_stale_goal_days,omitempty"`
49+
NudgeOpenQuestionMinAgeHours int `json:"nudge_open_question_min_age_hours,omitempty"`
4950
LLM *LLMConfig `json:"llm,omitempty"`
5051
Embedding *embedding.Config `json:"embedding,omitempty"`
5152
}
@@ -102,6 +103,7 @@ func DefaultConfig() Config {
102103
NudgeMaxPerDay: 1,
103104
NudgeCooldownHours: 24,
104105
NudgeStaleGoalDays: 7,
106+
NudgeOpenQuestionMinAgeHours: 24,
105107
}
106108
}
107109

@@ -198,6 +200,9 @@ func Resolve(cfg Config) Config {
198200
if cfg.NudgeStaleGoalDays > 0 {
199201
def.NudgeStaleGoalDays = cfg.NudgeStaleGoalDays
200202
}
203+
if cfg.NudgeOpenQuestionMinAgeHours > 0 {
204+
def.NudgeOpenQuestionMinAgeHours = cfg.NudgeOpenQuestionMinAgeHours
205+
}
201206
if cfg.LLM != nil {
202207
def.LLM = cfg.LLM
203208
}

internal/memory/extended/nudges.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,24 @@ func (em *ExtendedMemory) computeNudges(ctx context.Context, maxN int) ([]Nudge,
173173
log.Printf("extended memory: nudge open-loops failed: %v", err)
174174
return nil, nil
175175
}
176+
// Age-gate question atoms: per-turn extraction runs on the user message
177+
// BEFORE the assistant answers, so every freshly asked question looks
178+
// "unanswered" at extraction time. Nudging about a question that was just
179+
// answered (or is still being discussed) is exactly the kind of proactive
180+
// noise that destroys trust, so only questions older than
181+
// NudgeOpenQuestionMinAgeHours are candidates. Goals and intents are
182+
// unaffected — they have their own staleness window.
183+
minAge := time.Duration(em.openQuestionMinAgeHours()) * time.Hour
184+
cutoff := time.Now().UTC().Add(-minAge)
185+
candidates := openLoops[:0]
186+
for _, a := range openLoops {
187+
if a.Type == TypeQuestion && a.CreatedAt.After(cutoff) {
188+
continue
189+
}
190+
candidates = append(candidates, a)
191+
}
192+
openLoops = candidates
193+
176194
stale := em.staleGoals()
177195
var focus FocusState
178196
if em.userModel != nil {
@@ -270,6 +288,15 @@ func (em *ExtendedMemory) staleGoalDays() int {
270288
return DefaultConfig().NudgeStaleGoalDays
271289
}
272290

291+
// openQuestionMinAgeHours returns the minimum age before a question atom may
292+
// become a nudge candidate.
293+
func (em *ExtendedMemory) openQuestionMinAgeHours() int {
294+
if em.cfg.NudgeOpenQuestionMinAgeHours > 0 {
295+
return em.cfg.NudgeOpenQuestionMinAgeHours
296+
}
297+
return DefaultConfig().NudgeOpenQuestionMinAgeHours
298+
}
299+
273300
// loadNudgeState reads nudges.json. Missing or corrupt files yield a fresh
274301
// state: the anti-annoyance ledger must never wedge nudge delivery.
275302
func (em *ExtendedMemory) loadNudgeState() nudgeState {

internal/memory/extended/proactive_test.go

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -234,7 +234,8 @@ func newNudgeEM(t *testing.T, llm LLMClient, mutate func(*Config)) *ExtendedMemo
234234
mutate(c)
235235
}
236236
})
237-
seedAtom(t, em, nudgeTestAtomID, "How does the eviction policy work?", TypeQuestion, time.Hour)
237+
// Seeded 48h old so it passes the default 24h open-question nudge gate.
238+
seedAtom(t, em, nudgeTestAtomID, "How does the eviction policy work?", TypeQuestion, 48*time.Hour)
238239
return em
239240
}
240241

@@ -514,3 +515,52 @@ func TestNudgeStateRoundTrip(t *testing.T) {
514515
t.Errorf("last_fired_by_kind = %v, want %v", got.LastFiredByKind, want.LastFiredByKind)
515516
}
516517
}
518+
519+
// TestNudgeOpenQuestionAgeGate verifies freshly asked questions never become
520+
// nudge candidates: per-turn extraction runs before the assistant answers, so
521+
// a young "unanswered" question is usually about to be answered — nudging
522+
// about it is noise. Only questions older than the configured minimum age
523+
// reach the synthesis prompt; goals/intents are unaffected.
524+
func TestNudgeOpenQuestionAgeGate(t *testing.T) {
525+
llm := newMockLLM(`[]`)
526+
em := newProactiveEM(t, llm, func(c *Config) { c.ProactiveNudgesEnabled = boolPtr(true) })
527+
seedAtom(t, em, "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb2", "What is the fresh question?", TypeQuestion, time.Hour)
528+
seedAtom(t, em, "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb3", "What is the ancient question?", TypeQuestion, 72*time.Hour)
529+
seedAtom(t, em, "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb4", "I plan to refactor the scheduler.", TypeGoal, time.Hour)
530+
531+
if _, err := em.ProactiveNudges(context.Background(), 2); err != nil {
532+
t.Fatalf("ProactiveNudges failed: %v", err)
533+
}
534+
prompt := llm.lastUserPrompt()
535+
if strings.Contains(prompt, "fresh question") {
536+
t.Errorf("1h-old question should be gated out of the nudge prompt:\n%s", prompt)
537+
}
538+
if !strings.Contains(prompt, "ancient question") {
539+
t.Errorf("72h-old question should reach the nudge prompt:\n%s", prompt)
540+
}
541+
if !strings.Contains(prompt, "refactor the scheduler") {
542+
t.Errorf("young goal atom should be unaffected by the question gate:\n%s", prompt)
543+
}
544+
}
545+
546+
// TestNudgeOpenQuestionAgeGateConfigurable verifies the gate honors a custom
547+
// minimum age (and that non-positive values fall back to the default).
548+
func TestNudgeOpenQuestionAgeGateConfigurable(t *testing.T) {
549+
llm := newMockLLM(`[]`)
550+
em := newProactiveEM(t, llm, func(c *Config) {
551+
c.ProactiveNudgesEnabled = boolPtr(true)
552+
c.NudgeOpenQuestionMinAgeHours = 2
553+
})
554+
seedAtom(t, em, "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb2", "What is the fresh question?", TypeQuestion, time.Hour)
555+
seedAtom(t, em, "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb3", "What is the older question?", TypeQuestion, 3*time.Hour)
556+
if _, err := em.ProactiveNudges(context.Background(), 2); err != nil {
557+
t.Fatalf("ProactiveNudges failed: %v", err)
558+
}
559+
prompt := llm.lastUserPrompt()
560+
if strings.Contains(prompt, "fresh question") {
561+
t.Errorf("1h-old question should be gated out with a 2h gate")
562+
}
563+
if !strings.Contains(prompt, "older question") {
564+
t.Errorf("3h-old question should pass a 2h gate")
565+
}
566+
}

0 commit comments

Comments
 (0)