feat: add /todo and /session slash commands with opt-in stats tracking

- Add pkg/stats package for token usage tracking (mutex + atomic JSON persist)
- Add /todo command: reads TODO.md managed by AI via write_file/edit_file
- Add /session command: shows today + cumulative token usage stats
- Add --stats flag to gateway/agent for opt-in usage tracking (zero cost when off)
- Telegram: register /todo and /session as quick commands (skip placeholder)
- System prompt: instruct AI to maintain TODO.md with checkbox format
- Update all 6 README translations with chat commands docs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
dj-oyu 2026-02-20 17:35:56 +09:00
parent 07bb13c062
commit 6d3bbdfc95
13 changed files with 589 additions and 9 deletions

View file

@ -491,6 +491,7 @@ PicoClaw stocke les données dans votre workspace configuré (par défaut : `~/.
├── state/ # État persistant (dernier canal, etc.)
├── cron/ # Base de données des tâches planifiées
├── skills/ # Compétences personnalisées
├── TODO.md # Liste de tâches (gérée par l'IA, voir avec /todo)
├── AGENTS.md # Guide de comportement de l'Agent
├── HEARTBEAT.md # Invites de tâches périodiques (vérifiées toutes les 30 min)
├── IDENTITY.md # Identité de l'Agent
@ -802,10 +803,28 @@ picoclaw agent -m "Bonjour, comment ça va ?"
| `picoclaw agent -m "..."` | Discuter avec l'agent |
| `picoclaw agent` | Mode de discussion interactif |
| `picoclaw gateway` | Démarrer la passerelle |
| `picoclaw gateway --stats`| Démarrer avec suivi d'utilisation |
| `picoclaw status` | Afficher le statut |
| `picoclaw cron list` | Lister toutes les tâches planifiées |
| `picoclaw cron add ...` | Ajouter une tâche planifiée |
### Commandes de Chat
Ces commandes slash peuvent être envoyées dans n'importe quel canal de chat (Telegram, Discord, etc.) et répondent instantanément sans consommer de tokens LLM :
| Commande | Description |
| --- | --- |
| `/help` | Afficher les commandes disponibles |
| `/todo` | Afficher la liste des tâches (lit `TODO.md`) |
| `/session` | Afficher les statistiques d'utilisation des tokens (nécessite `--stats`) |
| `/session reset` | Réinitialiser les statistiques d'utilisation |
| `/show model` | Afficher le modèle actuel |
| `/list channels` | Lister les canaux activés |
> **Note** : `/todo` affiche le fichier `TODO.md` que l'IA maintient via ses outils `write_file`/`edit_file`. Demandez à l'IA d'ajouter, compléter ou organiser des tâches et elle mettra à jour le fichier automatiquement.
>
> **Note** : `/session` nécessite que la passerelle soit démarrée avec le flag `--stats`. Sans celui-ci, le suivi des statistiques est désactivé pour zéro surcharge.
### Tâches Planifiées / Rappels
PicoClaw prend en charge les rappels planifiés et les tâches récurrentes via l'outil `cron` :

View file

@ -445,6 +445,7 @@ PicoClaw は設定されたワークスペース(デフォルト: `~/.picoclaw
├── state/ # 永続状態(最後のチャネルなど)
├── cron/ # スケジュールジョブデータベース
├── skills/ # カスタムスキル
├── TODO.md # タスクリストAIが管理、/todo で表示)
├── AGENTS.md # エージェントの行動ガイド
├── HEARTBEAT.md # 定期タスクプロンプト30分ごとに確認
├── IDENTITY.md # エージェントのアイデンティティ
@ -752,8 +753,26 @@ HEARTBEAT_OK 応答 ユーザーが直接結果を受け取る
| `picoclaw agent -m "..."` | エージェントとチャット |
| `picoclaw agent` | インタラクティブチャットモード |
| `picoclaw gateway` | ゲートウェイを起動 |
| `picoclaw gateway --stats` | 使用量統計を有効にして起動 |
| `picoclaw status` | ステータスを表示 |
### チャットコマンド
以下のスラッシュコマンドはチャットチャネルTelegram、Discord 等で送信でき、LLM トークンを消費せず即座に応答します:
| コマンド | 説明 |
|---------|------|
| `/help` | 利用可能なコマンドを表示 |
| `/todo` | タスクリストを表示(`TODO.md` を読み取り) |
| `/session` | トークン使用量統計を表示(`--stats` が必要) |
| `/session reset` | 使用量統計をリセット |
| `/show model` | 現在のモデルを表示 |
| `/list channels` | 有効なチャネルを一覧表示 |
> **注意**: `/todo` は AI が `write_file`/`edit_file` ツールで管理する `TODO.md` を表示します。AI にタスクの追加・完了・整理を依頼すると、自動的にファイルが更新されます。
>
> **注意**: `/session` はゲートウェイを `--stats` フラグ付きで起動する必要があります。フラグなしではオーバーヘッドゼロで統計追跡は無効です。
## 🤝 コントリビュート&ロードマップ
PR 歓迎!コードベースは意図的に小さく読みやすくしています。🤗

View file

@ -485,6 +485,7 @@ PicoClaw stores data in your configured workspace (default: `~/.picoclaw/workspa
├── state/ # Persistent state (last channel, etc.)
├── cron/ # Scheduled jobs database
├── skills/ # Custom skills
├── TODO.md # Task list (managed by AI, view with /todo)
├── AGENTS.md # Agent behavior guide
├── HEARTBEAT.md # Periodic task prompts (checked every 30 min)
├── IDENTITY.md # Agent identity
@ -836,10 +837,28 @@ picoclaw agent -m "Hello"
| `picoclaw agent -m "..."` | Chat with the agent |
| `picoclaw agent` | Interactive chat mode |
| `picoclaw gateway` | Start the gateway |
| `picoclaw gateway --stats`| Start with usage tracking |
| `picoclaw status` | Show status |
| `picoclaw cron list` | List all scheduled jobs |
| `picoclaw cron add ...` | Add a scheduled job |
### Chat Commands
These slash commands can be sent in any chat channel (Telegram, Discord, etc.) and return instantly without consuming LLM tokens:
| Command | Description |
| ----------------- | ------------------------------------------------ |
| `/help` | Show available commands |
| `/todo` | Show your task list (reads `TODO.md`) |
| `/session` | Show token usage statistics (requires `--stats`) |
| `/session reset` | Reset usage statistics |
| `/show model` | Show current model |
| `/list channels` | List enabled channels |
> **Note**: `/todo` displays the `TODO.md` file that the AI maintains via its `write_file`/`edit_file` tools. Ask the AI to add, complete, or organize tasks and it will update the file automatically.
>
> **Note**: `/session` requires the gateway to be started with `--stats` flag. Without it, stats tracking is disabled for zero overhead.
### Scheduled Tasks / Reminders
PicoClaw supports scheduled reminders and recurring tasks through the `cron` tool:

View file

@ -492,6 +492,7 @@ O PicoClaw armazena dados no workspace configurado (padrão: `~/.picoclaw/worksp
├── state/ # Estado persistente (ultimo canal, etc.)
├── cron/ # Banco de dados de tarefas agendadas
├── skills/ # Skills personalizadas
├── TODO.md # Lista de tarefas (gerenciada pela IA, veja com /todo)
├── AGENTS.md # Guia de comportamento do Agente
├── HEARTBEAT.md # Prompts de tarefas periodicas (verificado a cada 30 min)
├── IDENTITY.md # Identidade do Agente
@ -803,10 +804,28 @@ picoclaw agent -m "Ola, como vai?"
| `picoclaw agent -m "..."` | Conversar com o agente |
| `picoclaw agent` | Modo de chat interativo |
| `picoclaw gateway` | Iniciar o gateway (para bots de chat) |
| `picoclaw gateway --stats` | Iniciar com rastreamento de uso |
| `picoclaw status` | Mostrar status |
| `picoclaw cron list` | Listar todas as tarefas agendadas |
| `picoclaw cron add ...` | Adicionar uma tarefa agendada |
### Comandos de Chat
Estes comandos de barra podem ser enviados em qualquer canal de chat (Telegram, Discord, etc.) e respondem instantaneamente sem consumir tokens LLM:
| Comando | Descrição |
| --- | --- |
| `/help` | Mostrar comandos disponíveis |
| `/todo` | Mostrar lista de tarefas (lê `TODO.md`) |
| `/session` | Mostrar estatísticas de uso de tokens (requer `--stats`) |
| `/session reset` | Resetar estatísticas de uso |
| `/show model` | Mostrar modelo atual |
| `/list channels` | Listar canais habilitados |
> **Nota**: `/todo` exibe o arquivo `TODO.md` que a IA mantém via ferramentas `write_file`/`edit_file`. Peça à IA para adicionar, completar ou organizar tarefas e ela atualizará o arquivo automaticamente.
>
> **Nota**: `/session` requer que o gateway seja iniciado com a flag `--stats`. Sem ela, o rastreamento de estatísticas é desabilitado para zero overhead.
### Tarefas Agendadas / Lembretes
O PicoClaw suporta lembretes agendados e tarefas recorrentes por meio da ferramenta `cron`:

View file

@ -472,6 +472,7 @@ PicoClaw lưu trữ dữ liệu trong workspace đã cấu hình (mặc định:
├── state/ # Trạng thái lưu trữ (kênh cuối cùng, v.v.)
├── cron/ # Cơ sở dữ liệu tác vụ định kỳ
├── skills/ # Kỹ năng tùy chỉnh
├── TODO.md # Danh sách tác vụ (AI quản lý, xem với /todo)
├── AGENTS.md # Hướng dẫn hành vi Agent
├── HEARTBEAT.md # Prompt tác vụ định kỳ (kiểm tra mỗi 30 phút)
├── IDENTITY.md # Danh tính Agent
@ -780,10 +781,28 @@ picoclaw agent -m "Xin chào"
| `picoclaw agent -m "..."` | Trò chuyện với agent |
| `picoclaw agent` | Chế độ chat tương tác |
| `picoclaw gateway` | Khởi động gateway (cho bot chat) |
| `picoclaw gateway --stats` | Khởi động với theo dõi sử dụng |
| `picoclaw status` | Hiển thị trạng thái |
| `picoclaw cron list` | Liệt kê tất cả tác vụ định kỳ |
| `picoclaw cron add ...` | Thêm tác vụ định kỳ |
### Lệnh Chat
Các lệnh gạch chéo sau có thể gửi trong bất kỳ kênh chat nào (Telegram, Discord, v.v.) và phản hồi ngay lập tức mà không tiêu tốn token LLM:
| Lệnh | Mô tả |
| --- | --- |
| `/help` | Hiển thị các lệnh khả dụng |
| `/todo` | Hiển thị danh sách tác vụ (đọc `TODO.md`) |
| `/session` | Hiển thị thống kê sử dụng token (yêu cầu `--stats`) |
| `/session reset` | Đặt lại thống kê sử dụng |
| `/show model` | Hiển thị mô hình hiện tại |
| `/list channels` | Liệt kê các kênh đã bật |
> **Lưu ý**: `/todo` hiển thị file `TODO.md` mà AI duy trì qua công cụ `write_file`/`edit_file`. Yêu cầu AI thêm, hoàn thành hoặc sắp xếp tác vụ và nó sẽ tự động cập nhật file.
>
> **Lưu ý**: `/session` yêu cầu gateway được khởi động với cờ `--stats`. Không có cờ này, theo dõi thống kê bị tắt để không có overhead.
### Tác vụ định kỳ / Nhắc nhở
PicoClaw hỗ trợ nhắc nhở theo lịch và tác vụ lặp lại thông qua công cụ `cron`:

View file

@ -451,6 +451,7 @@ PicoClaw 将数据存储在您配置的工作区中(默认:`~/.picoclaw/work
├── state/ # 持久化状态 (最后一次频道等)
├── cron/ # 定时任务数据库
├── skills/ # 自定义技能
├── TODO.md # 任务列表AI 管理,/todo 查看)
├── AGENTS.md # Agent 行为指南
├── HEARTBEAT.md # 周期性任务提示词 (每 30 分钟检查一次)
├── IDENTITY.md # Agent 身份设定
@ -670,10 +671,28 @@ picoclaw agent -m "你好"
| `picoclaw agent -m "..."` | 与 Agent 对话 |
| `picoclaw agent` | 交互式聊天模式 |
| `picoclaw gateway` | 启动网关 (Gateway) |
| `picoclaw gateway --stats` | 启用使用量统计 |
| `picoclaw status` | 显示状态 |
| `picoclaw cron list` | 列出所有定时任务 |
| `picoclaw cron add ...` | 添加定时任务 |
### 聊天命令
以下斜杠命令可在任何聊天频道Telegram、Discord 等)中发送,即时响应且不消耗 LLM Token
| 命令 | 描述 |
| --- | --- |
| `/help` | 显示可用命令 |
| `/todo` | 显示任务列表(读取 `TODO.md` |
| `/session` | 显示 Token 使用统计(需要 `--stats` |
| `/session reset` | 重置使用统计 |
| `/show model` | 显示当前模型 |
| `/list channels` | 列出已启用的频道 |
> **注意**: `/todo` 显示 AI 通过 `write_file`/`edit_file` 工具维护的 `TODO.md` 文件。让 AI 添加、完成或整理任务,它会自动更新文件。
>
> **注意**: `/session` 需要使用 `--stats` 标志启动网关。未启用时统计跟踪被禁用,零开销。
### 定时任务 / 提醒 (Scheduled Tasks)
PicoClaw 通过 `cron` 工具支持定时提醒和重复任务:

View file

@ -208,7 +208,7 @@ func printHelp() {
fmt.Println(" onboard Initialize picoclaw configuration and workspace")
fmt.Println(" agent Interact with the agent directly")
fmt.Println(" auth Manage authentication (login, logout, status)")
fmt.Println(" gateway Start picoclaw gateway")
fmt.Println(" gateway Start picoclaw gateway (--stats to enable usage tracking)")
fmt.Println(" status Show picoclaw status")
fmt.Println(" cron Manage scheduled tasks")
fmt.Println(" migrate Migrate from OpenClaw to PicoClaw")
@ -373,6 +373,7 @@ func migrateHelp() {
func agentCmd() {
message := ""
sessionKey := "cli:default"
enableStats := false
args := os.Args[2:]
for i := 0; i < len(args); i++ {
@ -380,6 +381,8 @@ func agentCmd() {
case "--debug", "-d":
logger.SetLevel(logger.DEBUG)
fmt.Println("🔍 Debug mode enabled")
case "--stats":
enableStats = true
case "-m", "--message":
if i+1 < len(args) {
message = args[i+1]
@ -406,7 +409,7 @@ func agentCmd() {
}
msgBus := bus.NewMessageBus()
agentLoop := agent.NewAgentLoop(cfg, msgBus, provider)
agentLoop := agent.NewAgentLoop(cfg, msgBus, provider, enableStats)
// Print agent startup info (only for interactive mode)
startupInfo := agentLoop.GetStartupInfo()
@ -518,13 +521,15 @@ func simpleInteractiveMode(agentLoop *agent.AgentLoop, sessionKey string) {
}
func gatewayCmd() {
// Check for --debug flag
enableStats := false
args := os.Args[2:]
for _, arg := range args {
if arg == "--debug" || arg == "-d" {
switch arg {
case "--debug", "-d":
logger.SetLevel(logger.DEBUG)
fmt.Println("🔍 Debug mode enabled")
break
case "--stats":
enableStats = true
}
}
@ -541,7 +546,11 @@ func gatewayCmd() {
}
msgBus := bus.NewMessageBus()
agentLoop := agent.NewAgentLoop(cfg, msgBus, provider)
agentLoop := agent.NewAgentLoop(cfg, msgBus, provider, enableStats)
if enableStats {
fmt.Println("✓ Stats tracking enabled")
}
// Print agent startup info
fmt.Println("\n📦 Agent Status:")

View file

@ -71,9 +71,17 @@ Your workspace is at: %s
- Memory: %s/memory/MEMORY.md
- Daily Notes: %s/memory/YYYYMM/YYYYMMDD.md
- Skills: %s/skills/{skill-name}/SKILL.md
- Task List: %s/TODO.md
%s
## Task Management
When users ask you to track tasks, reminders, or to-do items, maintain a TODO.md file in your workspace using write_file or edit_file.
Use standard markdown checkbox format:
- [ ] Pending task
- [x] Completed task
Group tasks by category when there are many. The user can view this list anytime with /todo.
## Important Rules
1. **ALWAYS use tools** - When you need to perform an action (schedule reminders, send messages, execute commands, etc.), you MUST call the appropriate tool. Do NOT just say you'll do it or pretend to do it.
@ -81,7 +89,7 @@ Your workspace is at: %s
2. **Be helpful and accurate** - When using tools, briefly explain what you're doing.
3. **Memory** - When remembering something, write to %s/memory/MEMORY.md`,
now, runtime, workspacePath, workspacePath, workspacePath, workspacePath, toolsSection, workspacePath)
now, runtime, workspacePath, workspacePath, workspacePath, workspacePath, workspacePath, toolsSection, workspacePath)
}
func (cb *ContextBuilder) buildToolsSection() string {

View file

@ -16,6 +16,9 @@ import (
"time"
"unicode/utf8"
"os"
"path/filepath"
"github.com/sipeed/picoclaw/pkg/bus"
"github.com/sipeed/picoclaw/pkg/channels"
"github.com/sipeed/picoclaw/pkg/config"
@ -24,6 +27,7 @@ import (
"github.com/sipeed/picoclaw/pkg/providers"
"github.com/sipeed/picoclaw/pkg/routing"
"github.com/sipeed/picoclaw/pkg/state"
"github.com/sipeed/picoclaw/pkg/stats"
"github.com/sipeed/picoclaw/pkg/tools"
"github.com/sipeed/picoclaw/pkg/utils"
)
@ -33,6 +37,7 @@ type AgentLoop struct {
cfg *config.Config
registry *AgentRegistry
state *state.Manager
stats *stats.Tracker // nil when --stats not passed
running atomic.Bool
summarizing sync.Map
fallback *providers.FallbackChain
@ -52,7 +57,7 @@ type processOptions struct {
NoHistory bool // If true, don't load session history (for heartbeat)
}
func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers.LLMProvider) *AgentLoop {
func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers.LLMProvider, enableStats ...bool) *AgentLoop {
registry := NewAgentRegistry(cfg, provider)
// Register shared tools to all agents
@ -76,11 +81,18 @@ func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers
providerCache[primaryName] = provider
}
// Create stats tracker if enabled
var statsTracker *stats.Tracker
if len(enableStats) > 0 && enableStats[0] && defaultAgent != nil {
statsTracker = stats.NewTracker(defaultAgent.Workspace)
}
return &AgentLoop{
bus: msgBus,
cfg: cfg,
registry: registry,
state: stateManager,
stats: statsTracker,
summarizing: sync.Map{},
fallback: fallbackChain,
providerCache: providerCache,
@ -429,7 +441,12 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, agent *AgentInstance, opt
// 3. Save user message to session
agent.Sessions.AddMessage(opts.SessionKey, "user", opts.UserMessage)
// 4. Run LLM iteration loop
// 4. Record user prompt for stats
if al.stats != nil {
al.stats.RecordPrompt()
}
// 5. Run LLM iteration loop
finalContent, iteration, err := al.runLLMIteration(ctx, agent, messages, opts)
if err != nil {
return "", err
@ -629,6 +646,15 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, agent *AgentInstance,
return "", iteration, fmt.Errorf("LLM call failed after retries: %w", err)
}
// Record token usage
if response.Usage != nil && al.stats != nil {
al.stats.RecordUsage(
response.Usage.PromptTokens,
response.Usage.CompletionTokens,
response.Usage.TotalTokens,
)
}
// Check if no tool calls - we're done
if len(response.ToolCalls) == 0 {
finalContent = response.Content
@ -785,6 +811,13 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, agent *AgentInstance,
})
if forceErr == nil && forceResp.Content != "" {
finalContent = forceResp.Content
if forceResp.Usage != nil && al.stats != nil {
al.stats.RecordUsage(
forceResp.Usage.PromptTokens,
forceResp.Usage.CompletionTokens,
forceResp.Usage.TotalTokens,
)
}
}
}
@ -1162,11 +1195,64 @@ func (al *AgentLoop) handleCommand(ctx context.Context, msg bus.InboundMessage)
default:
return fmt.Sprintf("Unknown switch target: %s", target), true
}
case "/todo":
return al.handleTodoCommand(), true
case "/session":
return al.handleSessionCommand(args), true
}
return "", false
}
// handleTodoCommand reads TODO.md from the workspace and returns its contents.
func (al *AgentLoop) handleTodoCommand() string {
agent := al.registry.GetDefaultAgent()
if agent == nil {
return "No agent configured."
}
todoPath := filepath.Join(agent.Workspace, "TODO.md")
data, err := os.ReadFile(todoPath)
if err != nil || len(strings.TrimSpace(string(data))) == 0 {
return "No tasks yet. Ask me to add a task and I'll maintain a TODO list for you."
}
content := string(data)
content = strings.ReplaceAll(content, "- [x] ", "✅ ")
content = strings.ReplaceAll(content, "- [X] ", "✅ ")
content = strings.ReplaceAll(content, "- [ ] ", "⬜ ")
return content
}
// handleSessionCommand returns usage statistics or resets them.
func (al *AgentLoop) handleSessionCommand(args []string) string {
if al.stats == nil {
return "Stats tracking is disabled. Start with --stats flag to enable.\nUsage: picoclaw gateway --stats"
}
if len(args) > 0 && args[0] == "reset" {
al.stats.Reset()
return "Session statistics have been reset."
}
s := al.stats.GetStats()
return fmt.Sprintf("Session Statistics\n\nToday (%s):\n Prompts: %d\n LLM calls: %d\n Tokens: %s (in: %s, out: %s)\n\nAll time (since %s):\n Prompts: %d\n LLM calls: %d\n Tokens: %s (in: %s, out: %s)",
s.Today.Date,
s.Today.Prompts,
s.Today.Requests,
stats.FormatTokenCount(s.Today.TotalTokens),
stats.FormatTokenCount(s.Today.PromptTokens),
stats.FormatTokenCount(s.Today.CompletionTokens),
s.Since.Format("2006-01-02"),
s.TotalPrompts,
s.TotalRequests,
stats.FormatTokenCount(s.TotalTokens),
stats.FormatTokenCount(s.TotalPromptTokens),
stats.FormatTokenCount(s.TotalCompletionTokens),
)
}
// extractPeer extracts the routing peer from inbound message metadata.
func extractPeer(msg bus.InboundMessage) *routing.RoutePeer {
peerKind := msg.Metadata["peer_kind"]

View file

@ -122,6 +122,14 @@ func (c *TelegramChannel) Start(ctx context.Context) error {
return c.commands.List(ctx, message)
}, th.CommandEqual("list"))
bh.HandleMessage(func(ctx *th.Context, message telego.Message) error {
return c.handleQuickCommand(ctx, message)
}, th.CommandEqual("todo"))
bh.HandleMessage(func(ctx *th.Context, message telego.Message) error {
return c.handleQuickCommand(ctx, message)
}, th.CommandEqual("session"))
bh.HandleMessage(func(ctx *th.Context, message telego.Message) error {
return c.handleMessage(ctx, &message)
}, th.AnyMessage())
@ -419,6 +427,52 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes
return nil
}
// handleQuickCommand processes commands that don't need an LLM call (e.g. /todo, /session).
// Unlike handleMessage, it skips the "Thinking..." placeholder for instant response.
func (c *TelegramChannel) handleQuickCommand(ctx context.Context, message telego.Message) error {
if message.From == nil {
return nil
}
user := message.From
senderID := fmt.Sprintf("%d", user.ID)
if user.Username != "" {
senderID = fmt.Sprintf("%d|%s", user.ID, user.Username)
}
if !c.IsAllowed(senderID) {
return nil
}
chatID := message.Chat.ID
content := message.Text
if content == "" {
return nil
}
peerKind := "direct"
peerID := fmt.Sprintf("%d", user.ID)
if message.Chat.Type != "private" {
peerKind = "group"
peerID = fmt.Sprintf("%d", chatID)
}
metadata := map[string]string{
"message_id": fmt.Sprintf("%d", message.MessageID),
"user_id": fmt.Sprintf("%d", user.ID),
"username": user.Username,
"first_name": user.FirstName,
"is_group": fmt.Sprintf("%t", message.Chat.Type != "private"),
"peer_kind": peerKind,
"peer_id": peerID,
}
// No "Thinking..." placeholder — send directly via message bus
c.HandleMessage(fmt.Sprintf("%d", user.ID), fmt.Sprintf("%d", chatID), content, nil, metadata)
return nil
}
func (c *TelegramChannel) downloadPhoto(ctx context.Context, fileID string) string {
file, err := c.bot.GetFile(ctx, &telego.GetFileParams{FileID: fileID})
if err != nil {

View file

@ -40,6 +40,8 @@ func (c *cmd) Help(ctx context.Context, message telego.Message) error {
/help - Show this help message
/show [model|channel] - Show current configuration
/list [models|channels] - List available options
/todo - Show your task list
/session - Show usage statistics
`
_, err := c.bot.SendMessage(ctx, &telego.SendMessageParams{
ChatID: telego.ChatID{ID: message.Chat.ID},

160
pkg/stats/tracker.go Normal file
View file

@ -0,0 +1,160 @@
package stats
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sync"
"time"
)
// DayStats holds token usage for a single day.
type DayStats struct {
Date string `json:"date"` // "2006-01-02"
PromptTokens int64 `json:"prompt_tokens"`
CompletionTokens int64 `json:"completion_tokens"`
TotalTokens int64 `json:"total_tokens"`
Requests int `json:"requests"` // LLM call count
Prompts int `json:"prompts"` // user message count
}
// Stats is the full snapshot returned by GetStats.
type Stats struct {
TotalPromptTokens int64 `json:"total_prompt_tokens"`
TotalCompletionTokens int64 `json:"total_completion_tokens"`
TotalTokens int64 `json:"total_tokens"`
TotalRequests int `json:"total_requests"`
TotalPrompts int `json:"total_prompts"`
Since time.Time `json:"since"`
Today DayStats `json:"today"`
}
// Tracker accumulates LLM usage statistics with mutex-protected atomic persistence.
type Tracker struct {
mu sync.Mutex
stats Stats
stateFile string
}
// NewTracker creates a tracker that persists to {workspace}/state/stats.json.
func NewTracker(workspace string) *Tracker {
stateDir := filepath.Join(workspace, "state")
os.MkdirAll(stateDir, 0755)
t := &Tracker{
stateFile: filepath.Join(stateDir, "stats.json"),
}
t.load()
// Initialise Since if this is a fresh tracker
if t.stats.Since.IsZero() {
t.stats.Since = time.Now()
}
// Lazy day-roll on startup
t.rollDay()
return t
}
// RecordUsage records tokens from a single LLM call.
func (t *Tracker) RecordUsage(prompt, completion, total int) {
t.mu.Lock()
defer t.mu.Unlock()
t.rollDay()
t.stats.Today.PromptTokens += int64(prompt)
t.stats.Today.CompletionTokens += int64(completion)
t.stats.Today.TotalTokens += int64(total)
t.stats.Today.Requests++
t.stats.TotalPromptTokens += int64(prompt)
t.stats.TotalCompletionTokens += int64(completion)
t.stats.TotalTokens += int64(total)
t.stats.TotalRequests++
t.save()
}
// RecordPrompt increments the user-message counter.
func (t *Tracker) RecordPrompt() {
t.mu.Lock()
defer t.mu.Unlock()
t.rollDay()
t.stats.Today.Prompts++
t.stats.TotalPrompts++
t.save()
}
// GetStats returns a snapshot of the current statistics.
func (t *Tracker) GetStats() Stats {
t.mu.Lock()
defer t.mu.Unlock()
t.rollDay()
return t.stats
}
// Reset zeroes all counters and re-initialises Since.
func (t *Tracker) Reset() {
t.mu.Lock()
defer t.mu.Unlock()
t.stats = Stats{
Since: time.Now(),
Today: DayStats{Date: today()},
}
t.save()
}
// rollDay resets Today if the date has changed. Must be called with mu held.
func (t *Tracker) rollDay() {
d := today()
if t.stats.Today.Date != d {
t.stats.Today = DayStats{Date: d}
}
}
func today() string {
return time.Now().Format("2006-01-02")
}
// save persists via temp-file + rename (atomic). Must be called with mu held.
func (t *Tracker) save() {
data, err := json.MarshalIndent(&t.stats, "", " ")
if err != nil {
return
}
tmp := t.stateFile + ".tmp"
if err := os.WriteFile(tmp, data, 0644); err != nil {
return
}
if err := os.Rename(tmp, t.stateFile); err != nil {
os.Remove(tmp)
}
}
// load reads the stats file from disk. Called once at init.
func (t *Tracker) load() {
data, err := os.ReadFile(t.stateFile)
if err != nil {
return
}
json.Unmarshal(data, &t.stats)
}
// FormatTokenCount formats a token count for display (e.g. 1.2K, 3.5M).
func FormatTokenCount(n int64) string {
switch {
case n >= 1_000_000:
return fmt.Sprintf("%.1fM", float64(n)/1_000_000)
case n >= 1_000:
return fmt.Sprintf("%.1fK", float64(n)/1_000)
default:
return fmt.Sprintf("%d", n)
}
}

147
pkg/stats/tracker_test.go Normal file
View file

@ -0,0 +1,147 @@
package stats
import (
"os"
"path/filepath"
"testing"
)
func TestNewTracker_Persistence(t *testing.T) {
dir := t.TempDir()
tr := NewTracker(dir)
tr.RecordUsage(100, 50, 150)
tr.RecordPrompt()
// Reload from disk
tr2 := NewTracker(dir)
s := tr2.GetStats()
if s.TotalTokens != 150 {
t.Errorf("expected TotalTokens=150, got %d", s.TotalTokens)
}
if s.TotalPromptTokens != 100 {
t.Errorf("expected TotalPromptTokens=100, got %d", s.TotalPromptTokens)
}
if s.TotalCompletionTokens != 50 {
t.Errorf("expected TotalCompletionTokens=50, got %d", s.TotalCompletionTokens)
}
if s.TotalRequests != 1 {
t.Errorf("expected TotalRequests=1, got %d", s.TotalRequests)
}
if s.TotalPrompts != 1 {
t.Errorf("expected TotalPrompts=1, got %d", s.TotalPrompts)
}
}
func TestTracker_Accumulation(t *testing.T) {
tr := NewTracker(t.TempDir())
tr.RecordUsage(10, 5, 15)
tr.RecordUsage(20, 10, 30)
tr.RecordPrompt()
tr.RecordPrompt()
tr.RecordPrompt()
s := tr.GetStats()
if s.TotalTokens != 45 {
t.Errorf("expected TotalTokens=45, got %d", s.TotalTokens)
}
if s.TotalRequests != 2 {
t.Errorf("expected TotalRequests=2, got %d", s.TotalRequests)
}
if s.TotalPrompts != 3 {
t.Errorf("expected TotalPrompts=3, got %d", s.TotalPrompts)
}
if s.Today.TotalTokens != 45 {
t.Errorf("expected Today.TotalTokens=45, got %d", s.Today.TotalTokens)
}
if s.Today.Requests != 2 {
t.Errorf("expected Today.Requests=2, got %d", s.Today.Requests)
}
}
func TestTracker_Reset(t *testing.T) {
tr := NewTracker(t.TempDir())
tr.RecordUsage(100, 50, 150)
tr.RecordPrompt()
tr.Reset()
s := tr.GetStats()
if s.TotalTokens != 0 {
t.Errorf("expected TotalTokens=0 after reset, got %d", s.TotalTokens)
}
if s.TotalRequests != 0 {
t.Errorf("expected TotalRequests=0 after reset, got %d", s.TotalRequests)
}
if s.TotalPrompts != 0 {
t.Errorf("expected TotalPrompts=0 after reset, got %d", s.TotalPrompts)
}
if s.Since.IsZero() {
t.Error("expected Since to be set after reset")
}
}
func TestTracker_DayRoll(t *testing.T) {
dir := t.TempDir()
tr := NewTracker(dir)
tr.RecordUsage(100, 50, 150)
// Manually set Today to a past date to simulate day change
tr.mu.Lock()
tr.stats.Today.Date = "2000-01-01"
tr.mu.Unlock()
tr.RecordUsage(10, 5, 15)
s := tr.GetStats()
// Today should only have the second call
if s.Today.TotalTokens != 15 {
t.Errorf("expected Today.TotalTokens=15 after day roll, got %d", s.Today.TotalTokens)
}
if s.Today.Requests != 1 {
t.Errorf("expected Today.Requests=1 after day roll, got %d", s.Today.Requests)
}
// Totals should include both
if s.TotalTokens != 165 {
t.Errorf("expected TotalTokens=165, got %d", s.TotalTokens)
}
}
func TestTracker_StateFileCreated(t *testing.T) {
dir := t.TempDir()
tr := NewTracker(dir)
tr.RecordUsage(1, 1, 2)
stateFile := filepath.Join(dir, "state", "stats.json")
if _, err := os.Stat(stateFile); os.IsNotExist(err) {
t.Error("expected stats.json to be created")
}
}
func TestFormatTokenCount(t *testing.T) {
tests := []struct {
n int64
want string
}{
{0, "0"},
{500, "500"},
{999, "999"},
{1000, "1.0K"},
{1200, "1.2K"},
{45200, "45.2K"},
{999999, "1000.0K"},
{1000000, "1.0M"},
{1200000, "1.2M"},
{3500000, "3.5M"},
}
for _, tt := range tests {
got := FormatTokenCount(tt.n)
if got != tt.want {
t.Errorf("FormatTokenCount(%d) = %q, want %q", tt.n, got, tt.want)
}
}
}