diff --git a/CONTEXT_COMMANDS_RESEARCH.md b/CONTEXT_COMMANDS_RESEARCH.md new file mode 100644 index 000000000..9adf5838a --- /dev/null +++ b/CONTEXT_COMMANDS_RESEARCH.md @@ -0,0 +1,241 @@ +# PicoClaw 上下文管理功能调研报告 + +## 📊 OpenClaw 原有功能 vs PicoClaw 当前状态 + +### OpenClaw 核心命令(PicoClaw 缺失) + +| 命令 | 功能 | PicoClaw 状态 | +|------|------|---------------| +| `/status` | 查看会话状态(模型、Token使用量、成本) | ❌ 未实现 | +| `/compact` | 手动压缩上下文(摘要化历史) | ❌ 未实现 | +| `/new` 或 `/reset` | 开始新会话(重置session) | ❌ 未实现 | +| `/usage` | 查看Token消耗详情 | ❌ 未实现 | +| `/context list` | 查看已加载的上下文 | ❌ 未实现 | + +--- + +## 🔍 OpenClaw /status 命令详解 + +### 功能 +显示当前会话的详细状态信息 + +### 输出内容 +| 信息 | 说明 | +|------|------| +| 当前模型 | 正在使用的AI模型 | +| 上下文使用量 | 已消耗的Token数量 | +| 最后响应Token | 上一轮对话的Token | +| 预估成本 | 本次会话费用(仅API Key用户可见) | +| Gateway状态 | 网关是否繁忙 | + +### 使用场景 +- 感觉对话变慢时检查 `/status` +- 上下文超过50%时考虑 `/new` +- 定期检查成本,避免超支 + +--- + +## 🔍 OpenClaw /compact 命令详解 + +### 功能 +将历史对话压缩成摘要,保留最近消息完整 + +### 命令格式 +``` +/compact # 默认压缩 +/compact 保留代码讨论 # 带指令的压缩 +``` + +### 工作原理 +1. 将旧的对话内容摘要化 +2. 保留最近的消息完整 +3. 摘要存储在transcript中 +4. 显著减少Token消耗 + +### 使用场景 +- 长任务开始前 +- 上下文接近上限 +- 保留重要上下文但省Token + +### /compact vs /new 对比 + +| 命令 | 效果 | 适用场景 | +|------|------|---------| +| `/new` | 完全重置,清空历史 | 切换任务 | +| `/compact` | 压缩历史,保留摘要 | 继续当前任务但省Token | + +--- + +## 📁 PicoClaw 现有上下文管理 + +### 已实现的功能 + +#### 1. 自动压缩 (Auto-compaction) +- **触发条件**: 上下文超过阈值 +- **配置项**: `summarize_message_threshold`, `summarize_token_percent` +- **实现位置**: `pkg/agent/context_legacy.go` + +#### 2. 强制压缩 (Force Compression) +- 当上下文超限时,丢弃最旧的50%消息 +- 保留完整的对话轮次(Turn) +- 创建压缩说明摘要 + +#### 3. 上下文管理器 +PicoClaw 支持两种上下文管理器: +- `legacy` (默认) - 基于摘要的压缩 +- `seahorse` - 高级向量检索 + +--- + +## 🎯 PicoClaw 需要补充的命令 + +### 1. /status 命令 +**功能**: 显示当前会话状态 + +```go +// 实现建议 +func statusCommand() *Definition { + return &Definition{ + Name: "status", + Aliases: []string{"s"}, + Description: "Show session status (model, tokens, cost)", + Handler: func(req *Request) error { + // 获取当前session信息 + session := getSession(req.SessionKey) + return req.Replyf( + "📊 Session Status\n\n"+ + "Model: %s\n"+ + "Context: %d / %d tokens (%.1f%%)\n"+ + "Messages: %d\n"+ + "Compactions: %d", + session.Model, + session.UsedTokens, + session.MaxTokens, + session.UsagePercent, + session.MessageCount, + session.CompactionCount, + ) + }, + } +} +``` + +### 2. /compact 命令 +**功能**: 手动触发上下文压缩 + +```go +// 实现建议 +func compactCommand() *Definition { + return &Definition{ + Name: "compact", + Aliases: []string{"c"}, + Description: "Compact session context (summarize history)", + Handler: func(req *Request) error { + // 调用现有的Compact功能 + agent := getAgent(req.AgentID) + agent.CompactContext(req.SessionKey, req.Text) + return req.Reply("✅ Context compacted successfully") + }, + } +} +``` + +### 3. /new 命令 +**功能**: 开始新的会话 + +```go +// 实现建议 +func newCommand() *Definition { + return &Definition{ + Name: "new", + Aliases: []string{"reset"}, + Description: "Start a new session (reset conversation)", + Handler: func(req *Request) error { + // 创建新的session + agent := getAgent(req.AgentID) + agent.NewSession(req.SessionKey) + return req.Reply("🆕 New session started. Previous context has been archived.") + }, + } +} +``` + +--- + +## 📂 相关代码文件 + +### PicoClaw 核心文件 +- `pkg/agent/context_manager.go` - 上下文管理器接口 +- `pkg/agent/context_legacy.go` - 现有压缩实现 +- `pkg/session/manager.go` - 会话管理 +- `pkg/commands/builtin.go` - 内置命令列表 +- `pkg/commands/cmd_*.go` - 各命令实现 + +### Telegram 集成 +- `pkg/channels/telegram/telegram.go` - Telegram通道 +- `pkg/channels/telegram/commands.go` - Telegram命令处理(需新增) + +--- + +## 🛠️ 实现计划 + +### 第一阶段:基础命令 +1. 实现 `/status` 命令 +2. 实现 `/new` 命令 +3. 实现 `/reset` 命令(别名) + +### 第二阶段:压缩命令 +4. 实现 `/compact` 命令 +5. 添加压缩计数追踪 +6. 优化压缩摘要质量 + +### 第三阶段:增强功能 +7. 实现 `/usage` 命令 +8. 实现 `/context` 命令 +9. 添加成本计算 + +--- + +## 📝 配置项建议 + +```json +{ + "agents": { + "defaults": { + "summarize_message_threshold": 20, + "summarize_token_percent": 75 + } + }, + "commands": { + "status": { + "show_cost": true, + "show_context_percent": true + }, + "compact": { + "default_summary": "会话摘要" + } + } +} +``` + +--- + +## 🔗 参考资源 + +- [OpenClaw Usage Guide](https://openclaw-ai.online/usage/) +- [OpenClaw Session Management Deep Dive](https://github.com/openclaw/openclaw/blob/main/docs/reference/session-management-compaction.md) +- [Compaction Documentation](https://openclaw.dog/docs/concepts/compaction/) + +--- + +## 📌 下一步行动 + +1. 在 `pkg/commands/` 目录下创建新命令文件 +2. 在 `builtin.go` 中注册新命令 +3. 确保 Telegram 通道正确路由这些命令 +4. 添加测试用例 +5. 更新文档 + +--- + +*调研完成时间: 2026-04-12* diff --git a/PICO_STARTUP_GUIDE.md b/PICO_STARTUP_GUIDE.md new file mode 100644 index 000000000..4a0fd3335 --- /dev/null +++ b/PICO_STARTUP_GUIDE.md @@ -0,0 +1,150 @@ +# PicoClaw 启动方式指南 + +## 🎯 推荐方式:Web UI Launcher(桌面用户) + +这是官方推荐的桌面用户使用方式: + +### 方式一:双击启动(推荐) + +1. 下载 PicoClaw + - 访问 https://picoclaw.io + - 下载对应平台的版本 + +2. 双击 `picoclaw-launcher.exe`(Windows)或 `picoclaw-launcher`(macOS/Linux) + - 浏览器会自动打开 http://localhost:18800 + +3. 在 Web UI 中配置: + - **Provider** 页面:配置 MiniMax API Key + - **Channel** 页面:配置 Telegram Bot Token + - **Gateway** 页面:启动服务 + +### 方式二:命令行启动 Web UI + +```bash +cd 下载目录 +picoclaw-launcher +# 在浏览器打开 http://localhost:18800 +``` + +### 远程访问(Docker/虚拟机) + +```bash +picoclaw-launcher -public +``` + +--- + +## 💻 命令行模式(服务器/无头环境) + +### 交互式对话 + +```bash +picoclaw agent +# 输入消息与 AI 对话 +``` + +### 单次消息模式 + +```bash +picoclaw agent -m "你好" +``` + +### 启动 Gateway(连接聊天平台) + +```bash +picoclaw gateway +``` + +--- + +## ⚙️ 配置文件位置 + +### 默认配置目录 + +- **Windows**: `C:\Users\你的用户名\.picoclaw\` +- **macOS/Linux**: `~/.picoclaw/` + +### 配置文件 + +- `config.json` - 主配置文件 +- `workspace/` - 工作区目录 + +### 初始化配置 + +如果需要重新初始化配置: + +```bash +picoclaw onboard +``` + +--- + +## 🔧 问题排查 + +### Web UI 中模型为空 + +如果 Web UI Launcher 中看不到配置的模型: + +1. 检查配置文件:`C:\Users\用户名\.picoclaw\config.json` +2. 确保 `model_list` 中包含模型配置 +3. 确保 `agents.defaults.model_name` 设置了默认模型 + +### MiniMax Token Plan 配置示例 + +```json +{ + "model_list": [ + { + "model_name": "MiniMax-M2.7", + "model": "minimax/MiniMax-M2.7", + "api_base": "https://api.minimaxi.com/v1", + "api_keys": ["你的Token Plan Key"] + } + ], + "agents": { + "defaults": { + "model_name": "MiniMax-M2.7" + } + } +} +``` + +### Telegram 连接配置 + +```json +{ + "channels": { + "telegram": { + "enabled": true, + "token": "你的Bot Token", + "allow_from": ["你的User ID"], + "proxy": "http://127.0.0.1:代理端口" + } + } +} +``` + +--- + +## 🚀 官方资源 + +- **官网**: https://picoclaw.io +- **文档**: https://docs.picoclaw.io +- **GitHub**: https://github.com/sipeed/picoclaw + +--- + +## 💡 最佳实践 + +1. **桌面用户**: 使用 Web UI Launcher +2. **服务器/无头环境**: 使用命令行模式 +3. **开发测试**: 使用 `picoclaw agent -m` 单次测试 +4. **生产环境**: 使用 `picoclaw gateway` 后台运行 + +--- + +## 🔒 安全提示 + +- 禁用 `deny_patterns` 后,PicoClaw 可以执行任何命令 +- 确保 Telegram Bot 的 `allow_from` 设置了你的 User ID +- 妥善保管 API Key,不要泄露 diff --git a/pkg/agent/context_legacy.go b/pkg/agent/context_legacy.go index 85e331ae9..873668f80 100644 --- a/pkg/agent/context_legacy.go +++ b/pkg/agent/context_legacy.go @@ -36,10 +36,15 @@ func (m *legacyContextManager) Assemble(_ context.Context, req *AssembleRequest) } func (m *legacyContextManager) Compact(_ context.Context, req *CompactRequest) error { + if req == nil { + return nil + } + switch req.Reason { case ContextCompressReasonProactive, ContextCompressReasonRetry: // Sync emergency compression — budget exceeded. if result, ok := m.forceCompression(req.SessionKey); ok { + m.al.incrementCompactionCount(req.SessionKey) m.al.emitEvent( EventKindContextCompress, m.al.newTurnEventScope("", req.SessionKey).meta(0, "forceCompression", "turn.context.compress"), @@ -51,6 +56,14 @@ func (m *legacyContextManager) Compact(_ context.Context, req *CompactRequest) e ) } case ContextCompressReasonSummarize: + if req.Manual { + agent := m.al.registry.GetDefaultAgent() + if agent == nil { + return nil + } + _, _, err := m.summarizeSessionWithOptions(agent, req.SessionKey, req.Instructions, true) + return err + } m.maybeSummarize(req.SessionKey) } return nil @@ -97,7 +110,7 @@ func (m *legacyContextManager) maybeSummarize(sessionKey string) { } }() logger.Debug("Memory threshold reached. Optimizing conversation history...") - m.summarizeSession(agent, sessionKey) + _, _, _ = m.summarizeSessionWithOptions(agent, sessionKey, "", false) }() } } @@ -169,19 +182,32 @@ func (m *legacyContextManager) forceCompression(sessionKey string) (compressionR } func (m *legacyContextManager) summarizeSession(agent *AgentInstance, sessionKey string) { + _, _, _ = m.summarizeSessionWithOptions(agent, sessionKey, "", false) +} + +func (m *legacyContextManager) summarizeSessionWithOptions( + agent *AgentInstance, + sessionKey string, + instructions string, + manual bool, +) (SessionSummarizePayload, bool, error) { ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) defer cancel() history := agent.Sessions.GetHistory(sessionKey) summary := agent.Sessions.GetSummary(sessionKey) - if len(history) <= 4 { - return + keepLast := 4 + if manual { + keepLast = 2 + } + if len(history) <= keepLast { + return SessionSummarizePayload{}, false, nil } - safeCut := findSafeBoundary(history, len(history)-4) + safeCut := findSafeBoundary(history, len(history)-keepLast) if safeCut <= 0 { - return + return SessionSummarizePayload{}, false, nil } keepCount := len(history) - safeCut toSummarize := history[:safeCut] @@ -203,7 +229,7 @@ func (m *legacyContextManager) summarizeSession(agent *AgentInstance, sessionKey } if len(validMessages) == 0 { - return + return SessionSummarizePayload{}, false, nil } const ( @@ -219,13 +245,16 @@ func (m *legacyContextManager) summarizeSession(agent *AgentInstance, sessionKey part1 := validMessages[:mid] part2 := validMessages[mid:] - s1, _ := m.summarizeBatch(ctx, agent, part1, "") - s2, _ := m.summarizeBatch(ctx, agent, part2, "") + s1, _ := m.summarizeBatch(ctx, agent, part1, "", instructions) + s2, _ := m.summarizeBatch(ctx, agent, part2, "", instructions) mergePrompt := fmt.Sprintf( "Merge these two conversation summaries into one cohesive summary:\n\n1: %s\n\n2: %s", s1, s2, ) + if instructions != "" { + mergePrompt += "\n\nAdditional focus for manual compaction: " + instructions + } resp, err := m.retryLLMCall(ctx, agent, mergePrompt, llmMaxRetries) if err == nil && resp.Content != "" { @@ -234,28 +263,34 @@ func (m *legacyContextManager) summarizeSession(agent *AgentInstance, sessionKey finalSummary = s1 + " " + s2 } } else { - finalSummary, _ = m.summarizeBatch(ctx, agent, validMessages, summary) + finalSummary, _ = m.summarizeBatch(ctx, agent, validMessages, summary, instructions) } if omitted && finalSummary != "" { finalSummary += "\n[Note: Some oversized messages were omitted from this summary for efficiency.]" } - if finalSummary != "" { - agent.Sessions.SetSummary(sessionKey, finalSummary) - agent.Sessions.TruncateHistory(sessionKey, keepCount) - agent.Sessions.Save(sessionKey) - m.al.emitEvent( - EventKindSessionSummarize, - m.al.newTurnEventScope(agent.ID, sessionKey).meta(0, "summarizeSession", "turn.session.summarize"), - SessionSummarizePayload{ - SummarizedMessages: len(validMessages), - KeptMessages: keepCount, - SummaryLen: len(finalSummary), - OmittedOversized: omitted, - }, - ) + if finalSummary == "" { + return SessionSummarizePayload{}, false, nil } + + agent.Sessions.SetSummary(sessionKey, finalSummary) + agent.Sessions.TruncateHistory(sessionKey, keepCount) + agent.Sessions.Save(sessionKey) + m.al.incrementCompactionCount(sessionKey) + + payload := SessionSummarizePayload{ + SummarizedMessages: len(validMessages), + KeptMessages: keepCount, + SummaryLen: len(finalSummary), + OmittedOversized: omitted, + } + m.al.emitEvent( + EventKindSessionSummarize, + m.al.newTurnEventScope(agent.ID, sessionKey).meta(0, "summarizeSession", "turn.session.summarize"), + payload, + ) + return payload, true, nil } func (m *legacyContextManager) findNearestUserMessage(messages []providers.Message, mid int) int { @@ -325,6 +360,7 @@ func (m *legacyContextManager) summarizeBatch( agent *AgentInstance, batch []providers.Message, existingSummary string, + customInstructions string, ) (string, error) { const ( llmMaxRetries = 3 @@ -339,6 +375,11 @@ func (m *legacyContextManager) summarizeBatch( sb.WriteString(existingSummary) sb.WriteString("\n") } + if customInstructions != "" { + sb.WriteString("Additional focus for this manual compaction: ") + sb.WriteString(customInstructions) + sb.WriteString("\n") + } sb.WriteString("\nCONVERSATION:\n") for _, msg := range batch { fmt.Fprintf(&sb, "%s: %s\n", msg.Role, msg.Content) diff --git a/pkg/agent/context_manager.go b/pkg/agent/context_manager.go index 5a5dfe97c..be06dcc28 100644 --- a/pkg/agent/context_manager.go +++ b/pkg/agent/context_manager.go @@ -45,9 +45,11 @@ type AssembleResponse struct { // CompactRequest is the input to Compact. type CompactRequest struct { - SessionKey string // session identifier - Reason ContextCompressReason // proactive_budget | llm_retry | summarize - Budget int // context window budget (used for retry aggressive compaction) + SessionKey string // session identifier + Reason ContextCompressReason // proactive_budget | llm_retry | summarize + Budget int // context window budget (used for retry aggressive compaction) + Instructions string // optional extra guidance for manual compaction + Manual bool // true when triggered explicitly by the user } // IngestRequest is the input to Ingest. diff --git a/pkg/agent/context_seahorse.go b/pkg/agent/context_seahorse.go index c6e5b30ac..1034649d3 100644 --- a/pkg/agent/context_seahorse.go +++ b/pkg/agent/context_seahorse.go @@ -136,7 +136,7 @@ func (m *seahorseContextManager) Compact(ctx context.Context, req *CompactReques } _, err := m.engine.Compact(ctx, req.SessionKey, seahorse.CompactInput{ - Force: req.Reason == ContextCompressReasonRetry, + Force: req.Reason == ContextCompressReasonRetry || req.Manual, Budget: &req.Budget, }) return err diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index f67802663..2743c76ff 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -69,6 +69,28 @@ type AgentLoop struct { activeRequests sync.WaitGroup reloadFunc func() error + + // Compression tracking (custom feature) + compressionCounters sync.Map // key: sessionKey, value: int64 +} + +func (al *AgentLoop) incrementCompactionCount(sessionKey string) { + if sessionKey == "" { + return + } + current, _ := al.compressionCounters.LoadOrStore(sessionKey, int64(0)) + al.compressionCounters.Store(sessionKey, current.(int64)+1) +} + +func (al *AgentLoop) getCompactionCount(sessionKey string) int { + if sessionKey == "" { + return 0 + } + value, ok := al.compressionCounters.Load(sessionKey) + if !ok { + return 0 + } + return int(value.(int64)) } // processOptions configures how a message is processed @@ -3576,6 +3598,99 @@ func (al *AgentLoop) buildCommandsRuntime( } return al.contextManager.Clear(ctx, opts.SessionKey) } + + rt.GetSessionStats = func() commands.SessionStats { + stats := commands.SessionStats{} + + if opts == nil || agent.Sessions == nil { + return stats + } + + stats.Version = config.GetVersion() + stats.SessionKey = opts.SessionKey + + history := agent.Sessions.GetHistory(opts.SessionKey) + summary := agent.Sessions.GetSummary(opts.SessionKey) + stats.MessageCount = len(history) + + tokenEstimate := 0 + for _, msg := range history { + tokenEstimate += EstimateMessageTokens(msg) + } + if summary != "" { + tokenEstimate += EstimateMessageTokens(providers.Message{ + Role: "system", + Content: summary, + }) + stats.HasSummary = true + } + stats.TokenEstimate = tokenEstimate + + stats.ContextWindow = agent.ContextWindow + if agent.ContextWindow > 0 { + stats.ContextPercent = float64(tokenEstimate) / float64(agent.ContextWindow) * 100 + } + + stats.ThinkEnabled = agent.ThinkingLevel != ThinkingOff + + return stats + } + + rt.CompactContext = func(instructions string) (int, error) { + if opts == nil { + return 0, fmt.Errorf("process options not available") + } + if al.contextManager == nil { + return 0, fmt.Errorf("context manager is not initialized") + } + if agent.Sessions == nil { + return 0, fmt.Errorf("sessions not initialized for agent") + } + + beforeHistory := agent.Sessions.GetHistory(opts.SessionKey) + + compactCtx, cancel := context.WithTimeout(context.Background(), 120*time.Second) + defer cancel() + + if err := al.contextManager.Compact(compactCtx, &CompactRequest{ + SessionKey: opts.SessionKey, + Reason: ContextCompressReasonSummarize, + Budget: agent.ContextWindow, + Instructions: instructions, + Manual: true, + }); err != nil { + return 0, err + } + + afterHistory := agent.Sessions.GetHistory(opts.SessionKey) + if len(afterHistory) < len(beforeHistory) { + al.incrementCompactionCount(opts.SessionKey) + } + return len(beforeHistory) - len(afterHistory), nil + } + + rt.NewSession = func() error { + if opts == nil { + return fmt.Errorf("process options not available") + } + if agent.Sessions == nil { + return fmt.Errorf("sessions not initialized for agent") + } + + al.compressionCounters.Store(opts.SessionKey, int64(0)) + + agent.Sessions.SetHistory(opts.SessionKey, make([]providers.Message, 0)) + agent.Sessions.SetSummary(opts.SessionKey, "") + agent.Sessions.Save(opts.SessionKey) + return nil + } + + rt.GetCompactionCount = func() int { + if opts == nil { + return 0 + } + return al.getCompactionCount(opts.SessionKey) + } } return rt } diff --git a/pkg/agent/manual_compact_test.go b/pkg/agent/manual_compact_test.go new file mode 100644 index 000000000..9bc924196 --- /dev/null +++ b/pkg/agent/manual_compact_test.go @@ -0,0 +1,82 @@ +package agent + +import ( + "context" + "strings" + "testing" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/providers" +) + +type promptCapturingProvider struct { + response string + prompts []string +} + +func (p *promptCapturingProvider) Chat( + _ context.Context, + messages []providers.Message, + _ []providers.ToolDefinition, + _ string, + _ map[string]any, +) (*providers.LLMResponse, error) { + if len(messages) > 0 { + p.prompts = append(p.prompts, messages[0].Content) + } + return &providers.LLMResponse{ + Content: p.response, + ToolCalls: []providers.ToolCall{}, + }, nil +} + +func (p *promptCapturingProvider) GetDefaultModel() string { + return "prompt-capturing-model" +} + +func TestLegacyCompact_Manual_UsesInstructionsAndCompactsSynchronously(t *testing.T) { + cfg := testConfig(t) + provider := &promptCapturingProvider{response: "manual compact summary"} + al := NewAgentLoop(cfg, bus.NewMessageBus(), provider) + + defaultAgent := al.registry.GetDefaultAgent() + if defaultAgent == nil { + t.Fatal("expected default agent") + } + + history := []providers.Message{ + {Role: "user", Content: "question one"}, + {Role: "assistant", Content: "answer one"}, + {Role: "user", Content: "question two"}, + {Role: "assistant", Content: "answer two"}, + {Role: "user", Content: "question three"}, + {Role: "assistant", Content: "answer three"}, + } + defaultAgent.Sessions.SetHistory("session-manual", history) + + err := al.contextManager.Compact(context.Background(), &CompactRequest{ + SessionKey: "session-manual", + Reason: ContextCompressReasonSummarize, + Instructions: "focus on decisions", + Manual: true, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + newHistory := defaultAgent.Sessions.GetHistory("session-manual") + if len(newHistory) >= len(history) { + t.Fatalf("expected compacted history, got %d messages (was %d)", len(newHistory), len(history)) + } + + summary := defaultAgent.Sessions.GetSummary("session-manual") + if !strings.Contains(summary, "manual compact summary") { + t.Fatalf("summary=%q, want manual summary content", summary) + } + if len(provider.prompts) == 0 || !strings.Contains(provider.prompts[0], "focus on decisions") { + t.Fatalf("expected compaction prompt to include manual instructions, got %v", provider.prompts) + } + if got := al.getCompactionCount("session-manual"); got != 1 { + t.Fatalf("compaction count=%d, want 1", got) + } +} diff --git a/pkg/commands/builtin.go b/pkg/commands/builtin.go index 39e76f752..c74604458 100644 --- a/pkg/commands/builtin.go +++ b/pkg/commands/builtin.go @@ -16,5 +16,8 @@ func BuiltinDefinitions() []Definition { clearCommand(), subagentsCommand(), reloadCommand(), + statusCommand(), + compactCommand(), + newCommand(), } } diff --git a/pkg/commands/cmd_compact.go b/pkg/commands/cmd_compact.go new file mode 100644 index 000000000..007da3cbf --- /dev/null +++ b/pkg/commands/cmd_compact.go @@ -0,0 +1,52 @@ +package commands + +import ( + "context" + "fmt" + "strings" +) + +func compactCommand() Definition { + return Definition{ + Name: "compact", + Description: "Compact session context and summarize older messages", + Usage: "/compact [instructions]", + Aliases: []string{"c"}, + Handler: func(_ context.Context, req Request, rt *Runtime) error { + if rt == nil { + return req.Reply(unavailableMsg) + } + + if rt.CompactContext == nil { + return req.Reply("Compaction is not available in the current context.") + } + + var instructions string + if fields := strings.Fields(req.Text); len(fields) > 1 { + instructions = strings.Join(fields[1:], " ") + } + + droppedMessages, err := rt.CompactContext(instructions) + if err != nil { + return req.Reply("Failed to compact context: " + err.Error()) + } + + if droppedMessages > 0 { + label := "(default)" + if instructions != "" { + label = instructions + } + return req.Reply(fmt.Sprintf( + "Context compacted.\n\n- Messages summarized: %d\n- Instructions: %s\n- Older context was condensed into the session summary.", + droppedMessages, + label, + )) + } + + if instructions != "" { + return req.Reply("Compaction requested, but the session was already small enough that no history was summarized.") + } + return req.Reply("Context is already compact enough. No summarization was needed.") + }, + } +} diff --git a/pkg/commands/cmd_context_test.go b/pkg/commands/cmd_context_test.go new file mode 100644 index 000000000..8b66a3e7a --- /dev/null +++ b/pkg/commands/cmd_context_test.go @@ -0,0 +1,81 @@ +package commands + +import ( + "context" + "strings" + "testing" +) + +func TestCompactCommand_PassesInstructionsToRuntime(t *testing.T) { + var gotInstructions string + ex := NewExecutor(NewRegistry(BuiltinDefinitions()), &Runtime{ + CompactContext: func(instructions string) (int, error) { + gotInstructions = instructions + return 3, nil + }, + }) + + var reply string + res := ex.Execute(context.Background(), Request{ + Text: "/compact focus on decisions", + Reply: func(text string) error { + reply = text + return nil + }, + }) + if res.Outcome != OutcomeHandled { + t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled) + } + if gotInstructions != "focus on decisions" { + t.Fatalf("instructions=%q, want %q", gotInstructions, "focus on decisions") + } + if !strings.Contains(reply, "Messages summarized: 3") { + t.Fatalf("reply=%q, want summarized count", reply) + } +} + +func TestStatusCommand_ShowsSummaryAndCompactions(t *testing.T) { + ex := NewExecutor(NewRegistry(BuiltinDefinitions()), &Runtime{ + GetModelInfo: func() (string, string) { + return "gpt-test", "openai" + }, + GetSessionStats: func() SessionStats { + return SessionStats{ + Version: "v1.2.3", + TokenEstimate: 1200, + ContextWindow: 8000, + ContextPercent: 15, + MessageCount: 6, + SessionKey: "agent:main:test", + ThinkEnabled: true, + HasSummary: true, + } + }, + GetCompactionCount: func() int { return 2 }, + }) + + var reply string + res := ex.Execute(context.Background(), Request{ + Text: "/status", + Reply: func(text string) error { + reply = text + return nil + }, + }) + if res.Outcome != OutcomeHandled { + t.Fatalf("outcome=%v, want=%v", res.Outcome, OutcomeHandled) + } + for _, want := range []string{ + "PicoClaw v1.2.3", + "gpt-test", + "History:* 6 messages", + "Summary:* present", + "Compactions:* 2", + "Runtime:* direct", + "Think:* on", + } { + if !strings.Contains(reply, want) { + t.Fatalf("reply=%q, want substring %q", reply, want) + } + } +} diff --git a/pkg/commands/cmd_new.go b/pkg/commands/cmd_new.go new file mode 100644 index 000000000..b6893f182 --- /dev/null +++ b/pkg/commands/cmd_new.go @@ -0,0 +1,42 @@ +package commands + +import "context" + +func newCommand() Definition { + return Definition{ + Name: "new", + Description: "Start a new session (reset conversation history)", + Usage: "/new", + Aliases: []string{"reset"}, + Handler: func(_ context.Context, req Request, rt *Runtime) error { + if rt == nil { + return req.Reply(unavailableMsg) + } + + if rt.NewSession == nil { + if rt.ClearHistory != nil { + if err := rt.ClearHistory(); err != nil { + return req.Reply("Failed to start new session: " + err.Error()) + } + return req.Reply( + "New session started.\n\n" + + "Previous conversation has been cleared.\n" + + "You now have a fresh context window.", + ) + } + return req.Reply(unavailableMsg) + } + + if err := rt.NewSession(); err != nil { + return req.Reply("Failed to start new session: " + err.Error()) + } + + return req.Reply( + "New session started.\n\n" + + "Previous conversation has been cleared.\n" + + "You now have a fresh context window.\n\n" + + "Tip: Use /compact to summarize older history before resetting a session.", + ) + }, + } +} diff --git a/pkg/commands/cmd_status.go b/pkg/commands/cmd_status.go new file mode 100644 index 000000000..cb05932ec --- /dev/null +++ b/pkg/commands/cmd_status.go @@ -0,0 +1,112 @@ +package commands + +import ( + "context" + "fmt" + "strings" +) + +func statusCommand() Definition { + return Definition{ + Name: "status", + Description: "Show current session status (model, context, compactions)", + Usage: "/status", + Aliases: []string{"s"}, + Handler: func(_ context.Context, req Request, rt *Runtime) error { + if rt == nil { + return req.Reply(unavailableMsg) + } + + modelName, provider := "Unknown", "Unknown" + if rt.GetModelInfo != nil { + modelName, provider = rt.GetModelInfo() + } + + stats := SessionStats{} + if rt.GetSessionStats != nil { + stats = rt.GetSessionStats() + } + + compactions := 0 + if rt.GetCompactionCount != nil { + compactions = rt.GetCompactionCount() + } + + if stats.ContextWindow == 0 { + stats.ContextWindow = 200000 + } + + version := stats.Version + if version == "" { + version = "dev" + } + + think := "off" + if stats.ThinkEnabled { + think = "on" + } + + var sessionInfo strings.Builder + sessionInfo.WriteString(fmt.Sprintf("🦞 *PicoClaw %s*\n\n", version)) + sessionInfo.WriteString(fmt.Sprintf("🧠 *Model:* %s/%s\n", provider, modelName)) + sessionInfo.WriteString(fmt.Sprintf( + "📚 *Context:* %s %s/%s (%.1f%%)\n", + makeContextBar(stats.ContextPercent), + formatTokens(stats.TokenEstimate), + formatTokens(stats.ContextWindow), + stats.ContextPercent, + )) + sessionInfo.WriteString(fmt.Sprintf("💬 *History:* %d messages\n", stats.MessageCount)) + if stats.HasSummary { + sessionInfo.WriteString("📝 *Summary:* present\n") + } else { + sessionInfo.WriteString("📝 *Summary:* none\n") + } + sessionInfo.WriteString(fmt.Sprintf("🧹 *Compactions:* %d\n", compactions)) + sessionInfo.WriteString("\n💡 *Tokens:* estimated from stored session history\n") + + if stats.SessionKey != "" { + sessionInfo.WriteString(fmt.Sprintf("🧵 *Session:* %s", stats.SessionKey)) + if stats.SessionUpdated != "" { + sessionInfo.WriteString(fmt.Sprintf(" - %s", stats.SessionUpdated)) + } + sessionInfo.WriteString("\n") + } + + sessionInfo.WriteString(fmt.Sprintf("⚙️ *Runtime:* direct · 🤖 *Think:* %s\n", think)) + return req.Reply(sessionInfo.String()) + }, + } +} + +func makeContextBar(percent float64) string { + const total = 20 + filled := int(percent / 100 * float64(total)) + if filled > total { + filled = total + } + if filled < 0 { + filled = 0 + } + empty := total - filled + + var bar strings.Builder + bar.WriteString("[") + for i := 0; i < filled; i++ { + bar.WriteString("█") + } + for i := 0; i < empty; i++ { + bar.WriteString("░") + } + bar.WriteString("]") + return bar.String() +} + +func formatTokens(tokens int) string { + if tokens >= 1000000 { + return fmt.Sprintf("%.1fm", float64(tokens)/1000000) + } else if tokens >= 1000 { + return fmt.Sprintf("%.1fk", float64(tokens)/1000) + } + return fmt.Sprintf("%d", tokens) +} diff --git a/pkg/commands/runtime.go b/pkg/commands/runtime.go index 5ba6a1bd2..8bcb3a9bc 100644 --- a/pkg/commands/runtime.go +++ b/pkg/commands/runtime.go @@ -17,4 +17,9 @@ type Runtime struct { SwitchChannel func(value string) error ClearHistory func() error ReloadConfig func() error + + GetSessionStats func() SessionStats + CompactContext func(instructions string) (droppedMessages int, err error) + NewSession func() error + GetCompactionCount func() int } diff --git a/pkg/commands/session_stats.go b/pkg/commands/session_stats.go new file mode 100644 index 000000000..15a474771 --- /dev/null +++ b/pkg/commands/session_stats.go @@ -0,0 +1,13 @@ +package commands + +type SessionStats struct { + MessageCount int + TokenEstimate int + ContextPercent float64 + ContextWindow int + SessionKey string + SessionUpdated string + Version string + ThinkEnabled bool + HasSummary bool +}