Skip to content

Commit 834491f

Browse files
committed
telegram: session management commands and enhanced session manager
- Add /sessions, /resume, /prune slash commands to Telegram bot - Enhance SessionManager with TTL-based cleanup, clarify channels, and cache-first loading - Add comprehensive tests for session management commands - Wire session commands through CLI telegram.go entry point
1 parent b9137bf commit 834491f

5 files changed

Lines changed: 508 additions & 14 deletions

File tree

cmd/odek/telegram.go

Lines changed: 119 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import (
2020
"github.com/BackendStack21/kode/internal/render"
2121
"github.com/BackendStack21/kode/internal/session"
2222
"github.com/BackendStack21/kode/internal/telegram"
23+
toolpkg "github.com/BackendStack21/kode/internal/tool"
2324
)
2425

2526
// chatMu serializes agent processing per chat to prevent same-chat message
@@ -197,30 +198,103 @@ func telegramCmd(args []string) error {
197198
), nil
198199
}
199200

200-
// Handle /prune [days] — clean up old sessions.
201+
// Handle /prune [days] — clean up old sessions and plans.
201202
if cmdName == "prune" {
202203
days := 30
203204
if strings.TrimSpace(argsStr) != "" {
204205
if d, err := strconv.Atoi(strings.TrimSpace(argsStr)); err == nil && d > 0 {
205206
days = d
206207
} else {
207-
return "❗ Usage: `/prune [days]`\n\nExample: `/prune 7` to remove sessions older than 7 days.", nil
208+
return "❗ Usage: `/prune [days]`\n\nExample: `/prune 7` to remove sessions and plans older than 7 days.", nil
208209
}
209210
}
210-
removed, err := sessionManager.PruneSessions(days)
211+
sessionsRemoved, err := sessionManager.PruneSessions(days)
211212
if err != nil {
212213
return fmt.Sprintf("❌ Failed to prune sessions: %v", err), nil
213214
}
214-
if removed == 0 {
215-
return fmt.Sprintf("📋 *Prune* — No sessions older than %d days found.", days), nil
215+
plansRemoved, err := sessionManager.PrunePlans(days)
216+
if err != nil {
217+
return fmt.Sprintf("❌ Failed to prune plans: %v", err), nil
218+
}
219+
total := sessionsRemoved + plansRemoved
220+
if total == 0 {
221+
return fmt.Sprintf("📋 *Prune* — Nothing older than %d days found.", days), nil
222+
}
223+
var b strings.Builder
224+
b.WriteString(fmt.Sprintf("🧹 *Pruned* — Removed items older than %d days:\n\n", days))
225+
if sessionsRemoved > 0 {
226+
b.WriteString(fmt.Sprintf("• %d session(s)\n", sessionsRemoved))
216227
}
217-
return fmt.Sprintf("🧹 *Pruned* — Removed %d session(s) older than %d days.", removed, days), nil
228+
if plansRemoved > 0 {
229+
b.WriteString(fmt.Sprintf("• %d plan(s)\n", plansRemoved))
230+
}
231+
return b.String(), nil
232+
}
233+
234+
// Handle /plan <description> — dispatch to agent for plan generation.
235+
if cmdName == "plan" {
236+
description := strings.TrimSpace(argsStr)
237+
if description == "" {
238+
return "❗ Usage: `/plan <description>`\n\nExample: `/plan Add user authentication with OAuth2`", nil
239+
}
240+
slug := telegram.Slugify(description)
241+
prompt := fmt.Sprintf(
242+
"Create a detailed implementation plan for: %s\n\n"+
243+
"Save the plan as a markdown file to `~/.odek/plans/%s.md`. "+
244+
"The plan should include:\n"+
245+
"- Overview and goals\n"+
246+
"- Architecture / design\n"+
247+
"- Implementation steps (bite-sized tasks)\n"+
248+
"- File paths and key code locations\n"+
249+
"- Testing strategy\n\n"+
250+
"Use your write_file tool to save the plan.",
251+
description, slug,
252+
)
253+
go handleChatMessage(chatID, prompt, bot, handler, sessionManager,
254+
resolved, systemMessage, handlerLog)
255+
return fmt.Sprintf("📝 *Planning* `%s`…\n\n_Generating plan for: %s_", slug, description), nil
256+
}
257+
258+
// Handle /plan-resume — inject most recent plan into session context.
259+
if cmdName == "plan_resume" {
260+
slug, content, err := telegram.MostRecentPlan()
261+
if err != nil {
262+
return fmt.Sprintf("❌ %v", err), nil
263+
}
264+
// Inject the plan as a system-level context message.
265+
cs, err := sessionManager.GetOrCreate(chatID)
266+
if err != nil {
267+
return fmt.Sprintf("❌ Failed to get session: %v", err), nil
268+
}
269+
contextMsg := fmt.Sprintf(
270+
"[Plan loaded: %s]\n\n%s\n\n---\nContinue working on this plan. "+
271+
"Use your tools to implement the next step.",
272+
slug, content,
273+
)
274+
cs.Messages = append(cs.Messages, llm.Message{Role: "user", Content: contextMsg})
275+
cs.LastActive = time.Now()
276+
if err := sessionManager.Save(chatID, cs.Messages); err != nil {
277+
return fmt.Sprintf("❌ Failed to save session: %v", err), nil
278+
}
279+
return fmt.Sprintf("📋 *Plan loaded*: `%s`\n\n_Injected into session context. Send a message to continue._", slug), nil
218280
}
219281

220282
return cmd.Handler(argsStr)
221283
}
222284

223285
handler.OnCallbackQuery = func(chatID int64, data string) (string, error) {
286+
// Route clarify callbacks — the user clicked Yes/No on a clarify question.
287+
if strings.HasPrefix(data, "clarify:") {
288+
answer := strings.TrimPrefix(data, "clarify:")
289+
if ch, ok := sessionManager.GetClarifyChannel(chatID); ok {
290+
select {
291+
case ch <- answer:
292+
default:
293+
// Channel full or closed — clarify already resolved.
294+
}
295+
}
296+
return "✅ Got it, thanks!", nil
297+
}
224298
return "", nil // approval callbacks are routed by the approver
225299
}
226300

@@ -362,7 +436,8 @@ func handleChatMessage(
362436

363437
// ── Typing Indicator ────────────────────────────────────────────
364438
// Send "typing" action every 4s while the agent runs (Telegram shows
365-
// it for ~5s). Stops when the goroutine's context is cancelled.
439+
// it for ~5s). Fire-and-forget so a hanging HTTP call doesn't block
440+
// the ticker and permanently stop the indicator.
366441
typingDone := make(chan struct{})
367442
defer close(typingDone)
368443
go func() {
@@ -371,7 +446,7 @@ func handleChatMessage(
371446
for {
372447
select {
373448
case <-ticker.C:
374-
bot.SendChatAction(chatID, "typing")
449+
go bot.SendChatAction(chatID, "typing")
375450
case <-typingDone:
376451
return
377452
}
@@ -399,6 +474,41 @@ func handleChatMessage(
399474
var allToolsMu sync.Mutex
400475
allTools := make(map[string]int)
401476

477+
// ── Clarify Tool ───────────────────────────────────────────────
478+
// Wire the clarify tool with a Telegram-native answer function.
479+
// When the agent calls clarify(question), the bot sends an inline
480+
// keyboard message and blocks until the user responds.
481+
agentTools := append([]odek.Tool{}, tools...)
482+
agentTools = append(agentTools, toolpkg.NewClarifyTool(func(question string) (string, error) {
483+
ch := make(chan string, 1)
484+
sessionManager.SetClarifyChannel(chatID, ch)
485+
defer sessionManager.DeleteClarifyChannel(chatID)
486+
487+
// Send the question with Yes/No buttons.
488+
replyMarkup := &telegram.InlineKeyboardMarkup{
489+
InlineKeyboard: [][]telegram.InlineKeyboardButton{
490+
{
491+
{Text: "Yes", CallbackData: "clarify:yes"},
492+
{Text: "No", CallbackData: "clarify:no"},
493+
},
494+
},
495+
}
496+
if _, err := bot.SendMessage(chatID, "❓ "+question,
497+
&telegram.SendOpts{ReplyMarkup: replyMarkup, ParseMode: "Markdown"}); err != nil {
498+
return "", fmt.Errorf("clarify: send message: %w", err)
499+
}
500+
501+
// Wait for the user to click a button (or timeout).
502+
select {
503+
case answer := <-ch:
504+
return answer, nil
505+
case <-time.After(10 * time.Minute):
506+
return "", fmt.Errorf("clarify: timed out waiting for response")
507+
case <-typingDone:
508+
return "", fmt.Errorf("clarify: task cancelled by /stop")
509+
}
510+
}))
511+
402512
agentCfg := odek.Config{
403513
Model: resolved.Model,
404514
BaseURL: resolved.BaseURL,
@@ -407,7 +517,7 @@ func handleChatMessage(
407517
SystemMessage: systemMessage,
408518
NoProjectFile: resolved.NoAgents,
409519
Thinking: resolved.Thinking,
410-
Tools: tools,
520+
Tools: agentTools,
411521
Renderer: rend,
412522
ToolEventHandler: func(event string, name string, data string) {
413523
traceMu.Lock()

internal/telegram/commands.go

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package telegram
33
import (
44
"fmt"
55
"strings"
6+
"time"
67
)
78

89
// CommandDescriptor describes a slash command and its handler.
@@ -69,6 +70,31 @@ func init() {
6970
Description: "Clean up old sessions (default: 30 days)",
7071
Handler: pruneHandler,
7172
},
73+
{
74+
Command: "plan",
75+
Description: "Create a new plan from a description",
76+
Handler: planHandler,
77+
},
78+
{
79+
Command: "plans",
80+
Description: "List all saved plans",
81+
Handler: plansHandler,
82+
},
83+
{
84+
Command: "plan_view",
85+
Description: "View a plan's full content by slug",
86+
Handler: planViewHandler,
87+
},
88+
{
89+
Command: "plan_delete",
90+
Description: "Delete a plan by slug",
91+
Handler: planDeleteHandler,
92+
},
93+
{
94+
Command: "plan_resume",
95+
Description: "Resume the most recent plan",
96+
Handler: planResumeHandler,
97+
},
7298
}
7399
}
74100

@@ -134,6 +160,65 @@ func pruneHandler(args string) (string, error) {
134160
return "🧹 *Prune* — Session cleanup is handled inline.", nil
135161
}
136162

163+
// ── Plan Command Handlers ──────────────────────────────────────────────
164+
165+
func planHandler(args string) (string, error) {
166+
return "📝 *Plan* — Plan creation is handled inline.", nil
167+
}
168+
169+
func plansHandler(args string) (string, error) {
170+
infos, err := ListPlans(20)
171+
if err != nil {
172+
return fmt.Sprintf("❌ Failed to list plans: %v", err), nil
173+
}
174+
if len(infos) == 0 {
175+
return "📋 *Plans* — No plans found.\n\nCreate one with `/plan <description>`", nil
176+
}
177+
var b strings.Builder
178+
b.WriteString("📋 *Plans*\n\n")
179+
for _, p := range infos {
180+
ago := time.Since(p.ModTime).Round(time.Minute)
181+
fmt.Fprintf(&b, "`%s` — %s ago\n", p.Slug, ago)
182+
if p.Preview != "" {
183+
fmt.Fprintf(&b, " _%s_\n", truncateStr(p.Preview, 60))
184+
}
185+
}
186+
b.WriteString("\nUse `/plan_view <slug>` to read a plan.")
187+
return b.String(), nil
188+
}
189+
190+
func planViewHandler(args string) (string, error) {
191+
slug := strings.TrimSpace(args)
192+
if slug == "" {
193+
return "❗ Usage: `/plan_view <slug>`\n\nUse `/plans` to see available plans.", nil
194+
}
195+
matched, content, err := ReadPlan(slug)
196+
if err != nil {
197+
return fmt.Sprintf("❌ %v", err), nil
198+
}
199+
// Telegram messages have a 4096 char limit. Truncate if needed.
200+
if len(content) > 3900 {
201+
content = content[:3900] + "\n\n… _(truncated — plan too long for Telegram)_"
202+
}
203+
return fmt.Sprintf("📄 *Plan: `%s`*\n\n%s", matched, content), nil
204+
}
205+
206+
func planDeleteHandler(args string) (string, error) {
207+
slug := strings.TrimSpace(args)
208+
if slug == "" {
209+
return "❗ Usage: `/plan_delete <slug>`\n\nUse `/plans` to see available plans.", nil
210+
}
211+
matched, err := DeletePlan(slug)
212+
if err != nil {
213+
return fmt.Sprintf("❌ %v", err), nil
214+
}
215+
return fmt.Sprintf("🗑️ *Plan deleted*: `%s`", matched), nil
216+
}
217+
218+
func planResumeHandler(args string) (string, error) {
219+
return "📋 *Plan Resume* — Handled inline.", nil
220+
}
221+
137222
// FindCommand returns the command descriptor with the matching name, or nil.
138223
func FindCommand(name string) *CommandDescriptor {
139224
for i := range DefaultCommands {
@@ -156,3 +241,11 @@ func CommandDescriptors() []BotCommand {
156241
}
157242
return descs
158243
}
244+
245+
// truncateStr shortens s to maxLen, appending "…" if trimmed.
246+
func truncateStr(s string, maxLen int) string {
247+
if len(s) <= maxLen {
248+
return s
249+
}
250+
return s[:maxLen] + "…"
251+
}

0 commit comments

Comments
 (0)